Skip to content

fix(desktop): 插件运行授权改由 Host 批准 receipt 持有,与可变安装目录解耦 - #1080

Merged
MagicLizi merged 12 commits into
makecindy:mainfrom
Yuewei-LL:fix/plugin-approved-state-integrity
Jul 31, 2026
Merged

fix(desktop): 插件运行授权改由 Host 批准 receipt 持有,与可变安装目录解耦#1080
MagicLizi merged 12 commits into
makecindy:mainfrom
Yuewei-LL:fix/plugin-approved-state-integrity

Conversation

@Yuewei-LL

@Yuewei-LL Yuewei-LL commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

这次改了什么

摘要

插件的「用户批准过什么」这件事,此前唯一的事实来源就是安装目录里的 ghost.json /
.cindy-trust.json / .disabled —— 而这个目录是可变的、产品里还有多条路径把用户和
Agent 引到那里去改(详情页可显示/复制/打开安装目录,Forge 不禁止拿已安装目录当源码
目录)。结果是:就地改写安装目录里的清单,可以让更新确认框的「旧版本」和权限 diff
以被改过的现场为基线显示「无新增」,未经确认的能力声明因此拿到运行授权。

这个 PR 把授权事实搬到安装目录之外:一次明确的安装/更新确认会写出一份 Host 持有的
批准 receipt(batch 的 manifest + trust + 启停态 + 随机 revision),放在
owner-scoped 的 ghost-install-state 状态根里;GhostManager.list() 的 manifest /
trust / enabled 一律只从 receipt 取,安装目录里那三样退化为旧版本兼容镜像。没有
receipt 或 receipt 损坏 = 不构成运行授权(列为停用、不许启用、不参与技能落链),
只能走一次完整重新确认恢复。

配套补齐三处对称性:更新事务把批准态投影成 token 由 Renderer 回传、Main 现读比对
(不一致就 state-changed 拒绝,与既有的 expectedPackageSha256 形成对称);Forge
的 scaffold / pack 按 realpath 拒绝 Host 受管根及其后代(含大小写与软链/junction
别名);越出沙箱的 skill 槽在确认时逐字节拷成带 revision 的批准快照,共享技能根的
链接指快照而不是可被改写的安装目录。

审查后追加(第 5 轮,commit bce8fab / 7545bc1 / dc2f4cb / 8220a50

审查中暴露出一个模式:「怎么读插件内容目录」这条判据在六处各写一遍(技能指纹、快照
拷贝、安装目录漂移指纹、随包种子指纹、种子复制、Forge 打包收集),另有五处各自
path.join 后再判一次类型,分别用 Dirent 类型位 / lstat / stat / realpath 钳制
实现。于是每一轮审查都能在其中一处找到没覆盖的角落——补一处、下一轮换另一处。所以这
一轮先消掉分叉本身(新增 ghostContentTree.ts 作为唯一判据,差异只允许以显式策略参数
表达),再在统一后的判据上修四条具体反馈。

其中一条不是 reviewer 提的,是收敛过程中自查发现,且比任何一条 review 反馈更硬:
原内容摘要编码 path + NUL + bytes + NUL 有歧义——文件内容可以合法包含 NUL,于是
{ a: "x\0b\0y" }{ a: "x", b: "y" } 在进入 SHA-256 前形成完全相同的字节流,即
批准指纹本身可被构造碰撞。现改为 cindy-ghost-content-v2 + UTF-8 路径长度前缀 + 每文件
定长摘要,并同步 bump receipt schemaVersion(见「是否存在 breaking change」第 5 项)。

另外三条对应 review thread:随包/安装目录指纹忽略非普通条目(塞一条链接即被判「与种子
逐字节相同」并自动批准)、既有技能快照被无条件信任(缺失快照走 hash-checked 重建、既有
快照直接早退,两条路判据强度是反的)、Forge 受管根只做单向包含判定(源目录是受管根的
祖先时,递归打包会把已安装插件字节、批准 receipt 与技能快照打进 .cindy)。

变更类型

  • feat 新功能
  • fix 缺陷修复
  • refactor / perf 重构或性能优化
  • docs / test / chore 文档、测试或工程维护
  • 其他:

范围

  • 关联 Issue / 需求插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636(不使用 Closes,理由见「明确不包含」第 1 条 —— 第 7 项
    加固要求只落了一半,是否结案请维护者判断)

  • 本 PR 包含(对应 插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636 第 4 节九项加固要求):

    # 加固要求 落点
    1 宿主侧批准 manifest(含 install receipt 与 revision),存在插件目录之外、不被插件源码流程写入 新增 main/cindy-brain/ghostInstallReceipt.ts;状态根 ownerScopedUserDataPath('ghost-install-state');构造期断言内容根与状态根互不包含
    2 确认框版本与权限 diff 只以批准 manifest 为基线 shared/ghost.ts 新增 diffInstalledGhostPermissionItems()installFlow.tsxGhostPluginPage.tsxplugin-market/service.ts 全部改吃它,无批准基线时把候选包全部权限当新增项列出
    3 运行期能力/授权判定移到批准 manifest GhostManager.list() 只从 receipt 取 manifest / trust / enabled;非 approved 一律 enabled: false
    4 packGhostDir 按 realpath 拒绝安装目录及其子目录,覆盖软链、junction、Windows 大小写绕过 forge.tspackGhostDir / scaffoldGhostDir 共用 forbiddenRootDirs,来源是 GhostManager.managedRootDirs();新增错误码 SOURCE_IS_INSTALLED_PLUGIN;本轮改为双向判定(源目录既不能落在受管根内,也不能是它的祖先),且递归收集不跟随链接进受管根
    5 ghosts:inspect / ghosts:update 贯穿并校验预期安装 revision,不一致即失效 ghostInstallApprovalToken() / isGhostInstallApprovalToken()ghosts:update 与市场安装都必填 expectedInstalledApproval,Main 现读比对,不一致返回 PRECONDITION_FAILED
    6 插件信任元数据(.cindy-trust.json)纳入宿主侧存储或签名校验 trust 移入 receipt 并在读取时严格校验;readInstalledTrust() 删除,安装目录里的文件降级为兼容镜像
    7 Forge/Agent 编辑前把插件复制进会话工作目录形成独立工作副本 只落了一半:受管根一律拒绝 + 编写手册第 7 节要求「先复制/迁出到工作目录中的新目录」;没有实现宿主侧自动复制
    8 回归测试覆盖版本 diff、权限 diff、状态竞态、软链、旧安装兼容、Windows/macOS 路径 GhostManager.test.ts / forge.test.ts / installFlow.test.tsx / service.test.ts 等新增用例:软链替换 locale 快照、大小写与 junction 别名、legacy 与损坏 receipt fail closed、revision 竞态、卸载清理、快照缺失时仍可停用、内容漂移拒绝重建、locale 键不一致失效
    9 更新 Forge 编写手册与工具契约,写明安装目录不能作源码目录 forge.ts 内嵌手册第 7 节、packages/cindy-tools/src/types.ts 错误码、docs/dev-rules/plugin-security-and-authoring.md

    另外补了两处实现上绕不开的配套:GhostManagerrunExclusiveMutation() 把内容
    目录与 receipt 的改写串成一条事务车道(否则 receipt 与目录可能各写一半);随包内置
    插件由 provisioning 在逐字节对账后自动补批准(见下方档 1)。

  • 明确不包含
    0. 批准状态根自身的写保护 —— 只剩消费侧窗口。 <userData>/ghost-install-state/
    同权限本机进程可写。审查过程中把能在写入侧关掉的都关掉了(commit 7545bc1):
    技能快照的字节指纹在每次写批准事实时都重新核对——接受既有快照前、复制到临时目录后、
    rename 就位后各一次(skillSnapshotMatchesReceipt 是唯一判据);技能对账在建立
    或保留共享链接前也重算整棵快照。因此「复制完到 rename 之间被改写」与「快照事后
    被就地改写」都会在下一次写批准事实或下一轮对账时暴露:对不上就删掉重建,重建仍要过
    安装目录的字节校验,安装字节也漂移时一律拒绝并退回完整重新确认。
    仍未关闭的是消费侧:对账那次核对之后、主 Agent 顺着共享技能链接实际读取之前,
    快照仍可被改写——Agent 的读取路径不在宿主控制内,宿主不做逐次校验。receipt 同理:
    有严格结构与字段校验(改坏即判 invalid、fail closed),但没有签名或 MAC,能写状态
    根的进程可以伪造一份结构合法的批准。彻底关闭需要签名/MAC 或 OS 级写保护,未做
    已按「写入侧已关 / 消费侧未关」两条分别登记进
    plugin-security-and-authoring.md 第 6 节;不得把「写入侧已核对」说成「消费时读到
    的一定是被批准的字节」。

    1. 安装内容的持续完整性校验。receipt 钉住的是授权事实,不是「安装字节此后没被改过」。
      逻辑页代码仍从 cindy-brain/<id>/ 现读,packageSha256 只是批准时点的来源指纹、
      运行期没有任何地方校验它。能写这个目录的本机进程仍可替换代码,只是被限制在
      此前已批准的权限集内运行、且无法再借改写 ghost.json 扩权。这个残留缺口已写进
      plugin-security-and-authoring.md 第 6 节,避免后人误以为已有内容完整性保证。
    2. 宿主侧自动把插件源码复制进工作目录插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636 第 7 项的后半)。当前只做到"拒绝 +
      手册要求 Agent 自己迁出"。
    3. 市场 release 哈希自动补批准。见下方「用户可见变化」档 2,这是需要单独拍板的
      产品/安全取舍,本 PR 刻意不做。
    4. 服务端与插件市场后端改动(不在本仓)。
  • 用户可见变化:升级到本版本后,存量安装分三档,差别很大,请按档看:

    档 1 · 随包内置插件(cindy-* 官方插件)—— 用户无感,无需任何操作。
    启动对账时 provisioning 已经把安装目录与随包种子目录逐字节比对过(hashDirContent
    相等,或刚刚整目录换入),此时宿主自动补写批准 receipt,原有启停态保持不变。
    这里的「自动」有权威字节可比:种子来自应用安装包本身。

    档 2 · 从插件市场安装的插件 —— 需要用户手动点一次「重新确认权限」,在此之前不工作。
    这类安装没有历史批准记录,升级后会被列为停用,卡片上出现「需要重新确认权限」徽标和
    「重新确认权限」按钮;点它会走市场同一 release 的重装确认流程,权限逐条按新增项列出,
    确认后恢复。已录入的凭证不会丢(保险库按插件 id 存,重新确认走的是 update 而不是
    卸载重装),插件的面板位置与配置也保留。

    但请注意这是本 PR 刻意做的取舍,值得单独拍板: 技术上完全可以像档 1 那样自动补
    批准 —— 市场侧有 release 记录,可以比对安装字节与 release 哈希。本 PR 没有这么做,
    理由是:市场哈希只能证明「安装内容等于某个 release 的字节」,不能证明「用户看过并
    同意过该 release 的权限集」
    。用它自动生成一份批准,就是再一次把「现场内容可信」
    当成「授权已获得」—— 这正是 插件授权事实源应与安装目录解耦:宿主需保存 approved manifest #636 的同类错误。存量安装的历史确认记录不存在,宿主无从
    证明用户当初同意的是哪一组权限。

    代价也要摆明:所有市场安装的插件在用户点确认之前都不工作,包括已配好凭证的插件,
    以及定时任务依赖的插件工具会在此期间失败(停用插件不注册工具、不参与技能落链)。

    如果维护者认为这个一次性摩擦不可接受,一个可行折中是:仅当安装字节与市场 release
    哈希完全一致、且该 release 的权限集与当前 manifest 完全一致时自动补 receipt,并在
    UI 明确标注「由市场记录自动恢复,未经你本人确认」。这需要产品与安全一起定,不该由
    本 PR 隐含决定,所以这里明确留给维护者拍板。

    档 3 · 本地手动装入的 .cindy 插件 —— 必须用户重新选一次包确认,无法自动化。
    没有任何权威哈希可比对(包文件早已不在手上),只能让用户重新选包走完整确认。

    另外:receipt 损坏(被外部改坏/截断)与档 2/3 走同一条恢复路径,只是文案不同
    bodyInvalid vs bodyLegacy),一样 fail closed 不许启用。

    两处已知毛边,请维护者一并看:

    1. 随包插件被隔离时,恢复提示是死路。 撤销陈旧批准失败会转进程内隔离,UI 因此显示
      invalid 态并提示「请重新选择安装包并确认权限」——但随包插件根本没有 .cindy 可选。
      fail closed 的方向是对的,正确文案应是「重启应用即恢复」(下一轮启动对账会重新补批准)。
      本 PR 未改:需要新增第三条 reapproval route + 四语文案,会引入新的 UI/术语门禁面,
      而触发前提是状态根不可写这种坏环境。建议单独一个小 PR 处理。
    2. 被熄灯的常驻随包插件,同一次会话内不会自动重启。 撤销批准时会连带熄灯运行中的实例
      (runtime / node broker / agent slot 三连);若后一轮对账把批准补回来,它仍保持沉睡,
      因为 spawnIfResident 在 reconcile 里只覆盖 outcome.installed,不覆盖
      outcome.approved;下次启动的全量扫描会把它拉起来。本 PR 未改:让它同会话内自动
      重启,需要给「后一轮补回批准」这条路径也加点火,而那条路径缺少「用户此刻想让它跑」
      这个信息,容易把本该沉睡的实例点亮——为消一个可解释的行为引入新风险不值当。
  • 是否存在 breaking change:有。

    1. 上述档 2 / 档 3 的存量安装需要用户手动重新确认一次权限才能继续使用(行为变化,
      非 API 变化)。
    2. IPC 契约收紧:ghosts:updatepluginMarket.install(对已安装目标)新增必填的
      expectedInstalledApproval,缺失或格式不合法直接 INVALID_PARAMS /
      PRECONDITION_FAILED。调用方只有 Renderer 自身,已同步。
    3. GhostManager.update()opts 由可选变必填(expectedInstalledApproval)。
    4. InstalledGhost 新增必填字段 approval,消费方已全部同步。
    5. GhostInstallReceipt schemaVersion 1 → 2,旧 receipt 一律失效。 内容摘要编码换成
      无歧义 framing(见「审查后追加」),旧 receipt 里的 skillContentSha256 在新校验器下
      无意义,因此 schemaVersion 不匹配直接判 invalid、fail closed、需重新确认——刻意不留
      「字段/版本不认就跳过校验」的兼容口子,那本身就是绕过点。对已发布版本的用户无影响
      (receipt 格式尚未随任何版本发布过);但在本分支上测过的环境里,已写出的 receipt 会
      全部失效:随包插件(档 1)下一轮启动对账自动补回,市场/本地安装(档 2 / 档 3)需要
      再点一次「重新确认权限」。

UI 变化

界面效果证据见评论 #issuecomment-5138786803pr636-light.png / pr636-dark.png
(Light / Dark 各一张全量状态图)+ pr636-ui-preview.html(自包含单文件,两模式并排)。
产出方式:真实 React 组件在 jsdom 渲染后导出的 DOM + 仓库 i18n 文案 + exportThemeColors()
从内置主题实时导出的 481 个 token + 仓库 tailwind 配置编译出的 CSS,非手绘示意图。
仍未做实机双模式目检(会话内无法重启宿主实例),按 DESIGN.md §10 如实声明。

UI 改动集中在两处,均为复用既有同级组件形态、只换文案与触发条件:

  1. 插件列表卡片GhostPluginPage.tsx):名称后新增「需要重新确认权限」chip
    徽标(与既有「有新版本」徽标同一形态:rounded-full bg-[var(--surface-chip)] px-2 py-0.5 text-10 text-[var(--text-secondary)],逐字一致);右侧动作区把「使用/更新」
    按钮换成「重新确认权限」按钮(与既有卡片更新按钮的 class 串逐字一致:h-8 rounded-lg border-[var(--border-default)] bg-transparent px-3 text-12);开关置灰
    disabled={toggleDisabled || needsReapproval})。
  2. 插件详情页GhostPluginDetailView.tsx):头部动作区把更新按钮换成「重新确认
    权限」按钮(与既有更新按钮 class 串逐字一致:h-10 rounded-full border-[var(--border-default)] bg-[var(--surface-elevated)] px-5 text-13);头部
    下方新增一块 role="status" 说明卡(复用本文件既有的 DETAIL_SURFACE_CLASS +
    rounded-xl px-4 py-3.5),标题 + 一句话说明为什么被停用、怎么恢复。
  • 引用的设计规范
    • DESIGN.md §10「Token Selection Rules for New UI」第 5 条:新增样式没有任何硬编码
      色值,全部走语义 token(--surface-chip--surface-elevated--border-default
      --text-primary / --text-secondary--surface-hover-soft--focus-ring),
      未引入 bg-[#xxx] dark:bg-[#xxx] 形态。
    • DESIGN.md §10「Light / Dark Dual-Mode Delivery Gate」:两种模式都由上述语义 token
      覆盖(实现层面完成)。按该节最后一条如实说明:本次没有做实机双模式目检,见
      「未执行的验证」;不把「复用了 themed 样式」当作双模式已验证。
    • DESIGN.md §4「Cards & Containers」:说明卡用 12px 容器圆角(rounded-xl)、
      --surface-elevated 底、无阴影。
    • DESIGN.md §5「Border Radius Scale」:详情页按钮取 pill(rounded-full)、徽标取
      pill、说明卡取 12px 容器档。一处需要说明的偏离:列表卡片内的「重新确认权限」
      按钮用的是 8px(rounded-lg)而非 pill —— 这是照抄同一位置既有「更新 / 使用」按钮
      的现状(GhostPluginPage.tsx 同一 flex 容器内三个按钮 class 串完全一致),本 PR
      没有引入新的偏离,也没有顺手改既有按钮(超出本 PR 范围)。若需要统一为 pill,
      建议单独开一个 UI 一致性 PR 一起改,以免混进安全修复的 diff。
    • DESIGN.md §11「Voice & Content」:文案用大白话说清「发生了什么 + 怎么办」,
      不出现 receipt / revision / approval 等实现词;zh-CN / en / ja / ko 四语同步新增,
      pnpm check:i18n-glossary 通过。

怎么验证的

自动验证

# 基线:已 merge origin/main @ f818cea0(merge commit 278f996b,非 rebase)
# 冲突两处已解:cindy-brain/index.ts 的 beforeRemove、plugin-security-and-authoring.md
# 的代码位置表;另有两处语义冲突(main 新增测试夹具缺 approval / onReapprove,本分支把
# 两者改成必填)。submodule 指针未变。

pnpm test:unit
结果:Test Files 8 failed | 1407 passed (1415)
      Tests 16960 passed | 25 skipped (16985)
      唯一失败点仍是 apps/desktop 的 8 个 cindy-media suite 在 collect 阶段报
      better-sqlite3 原生模块加载错误(本机 Windows 环境性问题,merge 前后同样 8 个,
      在未改动的 origin/main 上复现,详见「未执行的验证」第 1 条)。断言层 0 失败。

pnpm --filter desktop run --if-present typecheck
结果:PASS(merge 后、以及拆分出的 3 个实现 commit 各自跑过一次)

pnpm --filter cindy-tools run --if-present typecheck
结果:exit 0 —— 该包没有 typecheck script,按仓库门禁规定的 --if-present 语义自动跳过
      (不是"类型检查通过",此前正文把它写成 PASS 是不准确的,这里改正)

pnpm check:i18n-glossary
结果:PASS —— 术语表 27 条已裁决 / 24 条待讨论,en / zh-CN / ja / ko 无新增违规

pnpm check:dco
结果:PASS —— 9 commits signed off, 1 exempt (merge commit)

# 定向跑本次改动覆盖的全部目录
cd apps/desktop && pnpm exec vitest run --pool=forks --maxWorkers=6 \
  src/main/cindy-brain src/main/plugin-market src/main/ghost-panel-window \
  src/main/mcp-integrations src/renderer/cindy-brain src/renderer/features/plugin \
  src/renderer/__tests__/ghostCommandDecoration.test.ts \
  src/renderer/__tests__/ghostComposerPlacement.test.ts \
  src/renderer/__tests__/planModeComposerEntry.test.ts \
  src/renderer/lib/__tests__/ghostPanelBubbleState.test.ts
结果:PASS —— Test Files 135 passed (135) / Tests 1862 passed (1862)

# 每个拆分 commit 单独可编译、可通过(不是只有最终态绿)
vitest run src/main/cindy-brain:bce8faba 77 files / 1100 tests、
7545bc1e 77 / 1103、dc2f4cbc 77 / 1112,均 PASS

关于用例性质的说明(避免把弱用例当成强证据):下列用例中,
rejects bytes swapped after the hash check but before the snapshot copy finishes
keeps an install unusable when a stale approval cannot be revoked
applies the SKILL.md size ceiling to the bytes that actually landed in the snapshot
以及各条 drift/legacy/revision 用例,都逐一还原成修复前的行为验证过精确变红,是真回归闸。
唯一例外是 refuses to follow a link planted inside the skill directory when rebuilding
它是行为钉住而非回归闸——当前 libuv 就把 junction 与目录软链报成 link,判据从 Dirent
类型位换成逐条 lstat 前后结果相同;它的价值在于哪天 Node/libuv 改变 dirent 语义会立刻变红。

新增回归用例(节选,全部通过):

  • keeps manifest, enabled state, and trust independent from mutable install files
  • fails legacy and corrupt receipts closed until a fully reviewed update replaces them
  • rejects an update when the approved revision changed after review
  • keeps disabling possible when the approved skill snapshot is gone, and rebuilds it on enable
  • refuses to rebuild an enable-time snapshot from install bytes that drifted from the approved manifest
  • installed locale symlinks cannot replace the Host-approved locale snapshot
  • installed icon removal cannot replace the Host-approved icon snapshot
  • rejects Host-managed roots, descendants, case aliases, and junction aliases(pack 与 scaffold 各一份)
  • prunes skill snapshots left behind by superseded approval revisions
  • treats receipt cleanup failure after content removal as a completed uninstall
  • refuses to rebuild a snapshot when only the SKILL.md body drifted
  • refuses to rebuild a snapshot when a helper file was added to the skill directory
  • still heals a deleted snapshot when the installed skill bytes are untouched
  • invalidates a receipt whose skill content digests no longer match the manifest
  • rejects bytes swapped after the hash check but before the snapshot copy finishes
  • applies the SKILL.md size ceiling to the bytes that actually landed in the snapshot
  • keeps an install unusable when a stale approval cannot be revoked
  • revoking approval fails the install closed, and a later bundled approval heals it
  • refuses to follow a link planted inside the skill directory when rebuilding(行为钉住,见上)

第 5 轮新增(对应「审查后追加」,除注明者外均已还原判据验证过精确变红):

  • rejects a link in an intermediate path segment instead of hashing bytes from outside
  • never reads icon bytes from outside the plugin dir when a path segment is a link
  • invalidates a schema v1 receipt instead of trusting its legacy content digests
  • uses unambiguous framing when file bytes contain NUL separators
  • does not trust an already-present snapshot whose bytes were rewritten in place
  • refuses to keep a rewritten snapshot when the installed bytes drifted too
  • 完整摘要校验不通过时撤掉已有托管链接,不因目标未变而 kept
  • flags a planted link instead of folding it into the content hash
  • keeps type out of the hash so a sentinel-valued regular file stays distinguishable
  • re-seeds when a seed file was replaced by a link, even if its bytes could spoof a hash sentinel
  • re-seeds when a dot-named link was planted, even though dot entries stay out of the hash
  • 种子含非普通条目时跳过,不交换目录也不申请批准
  • rejects a source directory that contains a Host-managed root
  • does not follow a link inside the source dir into a Host-managed root
  • rejects a declared file that is a link instead of packing a package without it

手工验证

不涉及 / 未执行:本次改动在无法重启宿主 dev 实例的会话内完成,没有做实机手工验证。
需要实机确认的点(建议 reviewer 或后续在能重启的环境补):

  1. 升级后打开插件页,档 2 / 档 3 的存量安装是否如实显示徽标与说明卡、开关是否置灰;
  2. 点「重新确认权限」后,市场路线与本地包路线是否都真的弹出逐项权限确认;
  3. 档 1 的随包内置插件升级后是否完全无感(不出现徽标、启停态不变);
  4. 重新确认后,先前录入的凭证与面板位置是否保留。

未执行的验证

  1. pnpm test:unit 在本机没有全绿,失败与本改动无关,已用「暂存本次改动、在未改动
    origin/main 上跑同一组用例」的方式逐一确认为既存环境问题:

    • src/main/cindy-media/__tests__/8 个 suite 在 collect 阶段报
      SyntaxError: Unexpected identifier 'Database'(better-sqlite3 原生模块在本机
      Windows 下的加载问题)。这 8 个在 clean baseline 上完全相同地复现。本改动不涉及
      cindy-media
    • 另有 1 个 src/main/__tests__/clientEndpointsService.test.ts > 目录项换成了别的 inode → 不通过(Windows 的 ino/dev 语义差异)在较早一次运行
      中失败,同样在 clean baseline 上复现;在最终基线的复跑中它通过了,属于环境相关的
      不稳定用例。仓库已有 d2ea93a2 test(desktop): 修 Windows 本地跑不过的环境性用例
      这类先例。

    本改动没有新增任何失败,断言层 0 失败。最终以 CI 门禁为准。

  2. 双模式实机目检未做(Light / Dark 都只做到实现层面走语义 token)。原因同上:
    会话内无法重启宿主实例。按 DESIGN.md §10 双模式交付门槛最后一条如实声明。

  3. UI 截图 / 录屏未提供,原因同上。

  4. pnpm test:all 未跑(本 PR 不含 DB migration / 协议 / 原生层改动,按风险分层未追加)。

  5. macOS 路径行为未实测(realpath 大小写与 junction 别名用例在 Windows 上跑过;
    macOS 大小写不敏感卷的行为由同一 realpath 判定逻辑覆盖,但未在 macOS 实跑)。

  6. 本机 pnpm install 需要绕过引擎门。 merge 进来的 origin/main 把 lockfile 里的
    undici 提到 8.7.0(engines.node >= 22.19.0),本机 Node 22.14.0 会被 .npmrc
    engine-strict=true 拦住。本轮用一次性 pnpm install --config.engine-strict=false
    完成安装,未改动 lockfile 与 .npmrc(已核对 lockfile 与 origin/main 逐字节
    一致)。CI 的 Node 若 ≥22.19.0 则无影响;否则这是与本 PR 无关的独立问题。

风险

风险分类

  • 无已知风险
  • SQLite / migration
  • system prompt
  • 协议兼容
  • 权限 / 安全 / 用户数据
  • 原生层 / fingerprint / OTA
  • 跨平台差异
  • 其他:

影响与回滚

  • 影响范围:Desktop 插件(.cindy)的装入 / 更新 / 启停 / 卸载全链路,插件运行期
    能力授权判定,skill 槽落链目标,Forge 的 scaffold / pack 准入,插件页与插件市场 UI,
    以及依赖插件工具的定时任务(存量安装重新确认前不可用)。不涉及数据库 schema、
    不涉及 system prompt、不涉及 mobile 原生层与 runtime fingerprint。
  • 跨平台差异点:受管根判定依赖 realpath + 路径包含比较,需同时挡住 Windows 的
    大小写折叠与 directory junction、以及 macOS 大小写不敏感卷。Windows 侧有用例覆盖,
    macOS 未实跑(见「未执行的验证」第 5 条)。
  • 新增落盘位置ownerScopedUserDataPath('ghost-install-state') 下的
    <id>.json(0600)与 skill-snapshots/<id>/<revision>/。均为宿主状态、不含凭证明文,
    写入走同目录临时文件 + rename 原子提交,旧 revision 快照在 receipt 提交后回收。
  • 回滚 / 降级方式:代码层面 revert 本 PR 即可,不涉及数据库迁移,无需数据修复 ——
    回滚后 GhostManager 重新以安装目录为事实来源,遗留的 ghost-install-state 目录
    变成无人读取的孤立状态(可留可删)。启停态在整个过程中由安装目录里的 .disabled
    兼容镜像同步维护,所以回滚到旧客户端不会出现「批准态说停用、旧客户端却点亮」的
    错位。降级运行旧版本客户端同理可用。

提交前检查

  • 已 review 完整 diff
  • 每个 commit 都带 DCO 签名(git commit -s
  • UI 改动已在「UI 变化」注明引用的设计规范章节
  • 未提交凭证、令牌或授权文件
  • 已补充必要文档(docs/dev-rules/plugin-security-and-authoring.mdforge.ts 内嵌编写手册)
  • 已确认测试结果或说明未执行原因

给 reviewer 的两点提示

  1. 本 PR 改了 docs/dev-rules/plugin-security-and-authoring.md,会触发 review-pr 流程
    自动创建「规则文档改动讨论」issue 并暂停审查,等规则放行人确认(同 feat(plugins): unified plugin lifecycle readiness across discovery, enable, UI, scheduler, and credentials #668 / feat(desktop): 插件召唤卡改为气泡内标注行,法阵动画补终态编舞 #917
    情况)。规则改动与实现是一体的:把「授权事实由 Host receipt 持有」「Forge 源码区与
    受管根互斥」「启用需批准、停用必成功」写成审查判据,同时在第 6 节如实登记了残留的
    内容完整性缺口。
  2. feat(plugins): unified plugin lifecycle readiness across discovery, enable, UI, scheduler, and credentials #668feat(plugins): unified plugin lifecycle readiness)有 15 个文件重叠
    shared/ghost.tscindy-brain/index.tspreload.tsinstallErrorKey.ts
    GhostPluginPage.tsxGhostPluginDetailView.tsxvite-env.d.ts
    cindy-tools/src/types.tsplugin-security-and-authoring.md、四个 locale 等)。
    两者语义不重复(feat(plugins): unified plugin lifecycle readiness across discovery, enable, UI, scheduler, and credentials #668 不涉及批准状态),但都在扩 InstalledGhost 与插件 IPC 面,
    后合的一方需要 rebase。

此前插件「用户批准过什么」的唯一事实来源是安装目录里的 ghost.json /
.cindy-trust.json / .disabled,而这个目录是可变的,产品里还有多条路径把用户
与 Agent 引到那里去改(详情页可显示/复制/打开安装目录,Forge 不禁止拿已安装
目录当源码目录)。就地改写清单能让更新确认框的「旧版本」与权限 diff 以被改过
的现场为基线,未经确认的能力声明因此拿到运行授权。

把授权事实搬到安装目录之外:

- 新增 ghostInstallReceipt.ts:一次明确的安装/更新确认写出 Host 持有的
  receipt(批准 manifest + trust + 启停态 + 随机 revision),落在 owner-scoped
  的 ghost-install-state 状态根,构造期断言内容根与状态根互不包含;
  GhostManager.list() 的 manifest / trust / enabled 一律只从 receipt 取,
  安装目录里那三样退化为旧版本兼容镜像;
- 无 receipt(旧安装)或 receipt 损坏一律 fail closed:列为停用、不许启用、
  不参与技能落链,只能走一次完整重新确认恢复;插件页与详情页如实说明现状并
  给出「重新确认权限」入口,不让它看起来只是被用户关掉了;
- 版本与权限 diff 改吃 diffInstalledGhostPermissionItems,无批准基线时把候选
  包的全部权限当新增项逐条列出;
- 更新事务把批准态投影成 token 由 Renderer 回传、Main 现读比对,不一致即拒
  (state-changed / PRECONDITION_FAILED),与既有 expectedPackageSha256 形成
  对称;
- Forge 的 scaffold / pack 按 realpath 拒绝 Host 受管根及其后代,覆盖大小写
  折叠与软链 / junction 别名,pack 返回 SOURCE_IS_INSTALLED_PLUGIN,并同步
  内嵌编写手册与工具契约错误码;
- 越出沙箱的 skill 槽在确认时逐字节拷成带 revision 的批准快照,共享技能根的
  链接指快照而不是可被改写的安装目录,旧 revision 在 receipt 提交后回收;
- 内容目录与 receipt 的改写串成一条事务车道;启用需有效批准状态、停用无论
  环境如何都必须成功;随包内置插件由 provisioning 逐字节对账后自动补批准,
  用户无感。

安装内容的持续完整性校验仍未做:packageSha256 只是批准时点的来源指纹,运行期
没有任何地方校验它,逻辑页代码仍从可变安装目录现读。该残留缺口已写进
docs/dev-rules/plugin-security-and-authoring.md 第 6 节,避免后人误以为已有
内容完整性保证。

存量安装分三档影响,以及「市场 release 哈希是否应自动补批准」这一取舍,详见
PR 描述的「用户可见变化」——本次刻意不做自动补批准,留给维护者单独拍板。

关联 Issue: makecindy#636

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 11:48
@Yuewei-LL
Yuewei-LL requested a review from a team as a code owner July 30, 2026 11:48
Comment thread apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown

Greptile Summary

本 PR 将插件运行授权从可变安装目录迁移到 Host 持有的批准 receipt,并补齐更新竞态校验、技能快照校验、受管目录隔离及重新确认 UI。

  • GhostManager 以 receipt 作为 manifest、trust、启停状态和 revision 的唯一授权事实,无有效 receipt 时保持停用。
  • 安装、更新、启停和卸载共用串行事务通道,更新时校验 Renderer 审阅过的批准状态 token。
  • 技能槽改为链接到带 revision 的批准快照,并在发布后及每次链接对账时校验完整内容摘要。
  • Forge 拒绝将 Host 受管根、其后代或包含受管根的祖先目录作为源码,并拒绝通过链接递归进入受管根。
  • Renderer 为缺失或损坏批准状态的插件提供重新确认入口及四语提示。

Confidence Score: 5/5

当前代码已关闭既有线程指出的快照复制与发布校验缺口,本次跟进审查未发现阻止合并的残留问题。

现有实现会在临时快照发布前、rename 就位后以及共享链接建立或保留前复核完整内容摘要;校验失败、异常或 revision 不一致都会跳过目标状态并撤销已有链接,因此既有审查问题已不再可达。

Important Files Changed

Filename Overview
apps/desktop/src/main/cindy-brain/GhostManager.ts 将插件授权事实迁移到 Host receipt,串行化生命周期写操作,并在启用、更新和技能对账路径上执行 fail-closed 校验。
apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts 实现 schema v2 receipt、无歧义内容摘要、revision 技能快照及复制前后的一致性复核。
apps/desktop/src/main/cindy-brain/ghostContentTree.ts 统一插件内容遍历、路径段链接拒绝、普通文件判定和摘要编码策略。
apps/desktop/src/main/cindy-brain/skillSlot.ts 在建立或保留共享技能链接前强制验证批准快照,失败或异常时撤销链接。
apps/desktop/src/main/cindy-brain/forge.ts 阻止 scaffold 和 pack 使用受管根、其后代、其祖先或链接别名作为插件源码。
apps/desktop/src/shared/ghost.ts 扩展安装批准状态、token、权限差异及 InstalledGhost 的共享契约。
apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx 为需要重新确认的插件显示状态徽标、禁用开关并提供恢复入口。
apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx 在插件详情页展示重新确认说明和操作入口。

Sequence Diagram

sequenceDiagram
  participant User as 用户
  participant Renderer
  participant Main as GhostManager
  participant Receipt as Host receipt
  participant Snapshot as 技能快照
  participant Reconciler as 技能链接对账
  participant Agent

  User->>Renderer: 确认安装或更新
  Renderer->>Main: 包 + expectedInstalledApproval
  Main->>Receipt: 比对当前批准 revision
  Main->>Main: 校验并切换插件内容
  Main->>Snapshot: 复制、摘要校验、发布后复核
  Main->>Receipt: 原子写入新批准事实
  Main->>Reconciler: 广播状态变化并调度对账
  Reconciler->>Receipt: 复核当前 revision
  Reconciler->>Snapshot: 重算完整内容摘要
  alt 快照有效
    Reconciler->>Agent: 建立或保留共享技能链接
  else 快照无效
    Reconciler->>Agent: 撤销已有链接
  end
Loading

Reviews (8): Last reviewed commit: "fix(desktop): 停用镜像丢失不再翻转随包批准的启停态,随包批准门加运..." | Re-trigger Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens Desktop plugin authorization by moving the “what the user approved” source of truth out of the mutable install directory into a Host-owned install receipt (with a random approval revision), and then wiring update/install flows, runtime gating, Forge authoring constraints, and UI affordances around that receipt-backed approval state.

Changes:

  • Introduces Host-owned install receipts (manifest/trust/enabled/revision + locale/icon snapshots) and makes GhostManager.list() derive runtime authorization solely from receipts (fail-closed when missing/corrupt).
  • Adds cross-process “approval token” binding to updates/market installs to detect state changes after permission review and reject with PRECONDITION_FAILED.
  • Tightens Forge pack/scaffold to reject Host-managed roots (install root + approval-state root), and redirects skill-slot linking to approval-revision snapshots instead of the mutable install directory.

Reviewed changes

Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/cindy-tools/src/types.ts Adds structured Forge refusal code for managed-root sources.
docs/dev-rules/plugin-security-and-authoring.md Documents Host-owned approval receipts, fail-closed behavior, and Forge source isolation.
apps/desktop/src/shared/ghost.ts Adds GhostInstallApproval, approval token helpers, and receipt-based permission diff helper.
apps/desktop/src/renderer/vite-env.d.ts Updates renderer API typings for update/install approval token binding.
apps/desktop/src/renderer/lib/tests/ghostPanelBubbleState.test.ts Updates test fixtures for required InstalledGhost.approval.
apps/desktop/src/renderer/i18n/locales/zh-CN/common.json Adds reapproval UI strings and approval-required error copy.
apps/desktop/src/renderer/i18n/locales/ko/common.json Adds reapproval UI strings and approval-required error copy.
apps/desktop/src/renderer/i18n/locales/ja/common.json Adds reapproval UI strings and approval-required error copy.
apps/desktop/src/renderer/i18n/locales/en/common.json Adds reapproval UI strings and approval-required error copy.
apps/desktop/src/renderer/features/plugin/lib/pluginMarketPresentation.ts Adds market reapproval routing + eligibility logic.
apps/desktop/src/renderer/features/plugin/lib/ghostPluginViewModel.ts Exposes approval state in list/detail view models.
apps/desktop/src/renderer/features/plugin/lib/tests/pluginMarketPresentation.test.ts Tests market reapproval routing/eligibility helpers.
apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx Integrates receipt-based permission diff + reapproval action + token binding.
apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx Adds reapproval CTA, disables toggles, and shows status notice when unapproved.
apps/desktop/src/renderer/features/plugin/tests/GhostPluginDetailSections.test.tsx Tests detail UI behavior for legacy/invalid approval states.
apps/desktop/src/renderer/features/plugin/tests/GhostPluginCard.test.tsx Tests card UI behavior for legacy/invalid approval states.
apps/desktop/src/renderer/cindy-brain/installFlow.tsx Uses receipt-based permission diff + binds approval token on updates.
apps/desktop/src/renderer/cindy-brain/installErrorKey.ts Maps approval-related PRECONDITION_FAILED to “reapproval required” UI error.
apps/desktop/src/renderer/cindy-brain/tests/useInstalledGhosts.test.tsx Updates installed ghost fixtures to include approval.
apps/desktop/src/renderer/cindy-brain/tests/installFlow.test.tsx Adds tests for passing approval token + “all permissions added” when unapproved.
apps/desktop/src/renderer/cindy-brain/tests/ghostTabPlugins.test.tsx Updates fixtures to include approval.
apps/desktop/src/renderer/cindy-brain/tests/ghostPluginViewModel.test.ts Tests carrying approval state through view-model mapping.
apps/desktop/src/renderer/cindy-brain/tests/ghostPanels.test.tsx Updates fixtures to include approval.
apps/desktop/src/renderer/cindy-brain/tests/GhostPanelBubbleLayer.test.tsx Updates fixtures to include approval.
apps/desktop/src/renderer/tests/planModeComposerEntry.test.ts Updates fixtures to include approval.
apps/desktop/src/renderer/tests/ghostComposerPlacement.test.ts Updates fixtures to include approval.
apps/desktop/src/renderer/tests/ghostCommandDecoration.test.ts Updates fixtures to include approval.
apps/desktop/src/preload/preload.ts Updates exposed IPC signatures for approval token binding.
apps/desktop/src/main/plugin-market/service.ts Enforces approval-token preconditions + receipt-based permission diff for market installs/updates.
apps/desktop/src/main/plugin-market/registerIpc.ts Validates expectedInstalledApproval tokens for market IPC.
apps/desktop/src/main/plugin-market/tests/service.test.ts Adds tests for approval-token races and precondition failures during download.
apps/desktop/src/main/mcp-integrations/ghost.ts Passes managed-root forbiddance into Forge scaffold/pack MCP paths.
apps/desktop/src/main/ghost-panel-window/tests/controller.test.ts Updates fixtures to include approval.
apps/desktop/src/main/cindy-brain/skillSlot.ts Repoints shared-skill links to approval-revision snapshots and manages two roots.
apps/desktop/src/main/cindy-brain/runtime/tests/GhostRuntime.test.ts Updates fixtures to include approval.
apps/desktop/src/main/cindy-brain/index.ts Wires state root into GhostManager, maps new rejections to IPC errors, and binds market updates.
apps/desktop/src/main/cindy-brain/GhostManager.ts Implements receipt-backed listing/enable/update/uninstall, mutation serialization, and built-in approvals.
apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts New Host-owned receipt store with strict read/atomic write + skill snapshot management.
apps/desktop/src/main/cindy-brain/forge.ts Adds managed-root forbiddance to scaffold/pack, and new structured error code.
apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts Reports byte-reconciled bundled manifests for automatic receipt approval.
apps/desktop/src/main/cindy-brain/tests/skillSlot.test.ts Updates tests for required approval-state root and snapshot link targeting.
apps/desktop/src/main/cindy-brain/tests/ghostSetupManifestTracker.test.ts Updates fixtures to include approval.
apps/desktop/src/main/cindy-brain/tests/GhostManager.test.ts Adds extensive receipt/approval fail-closed, snapshot, and race-condition coverage.
apps/desktop/src/main/cindy-brain/tests/fsSlot.test.ts Updates fixtures to include approval.
apps/desktop/src/main/cindy-brain/tests/forge.test.ts Adds tests for managed-root pack/scaffold refusal and guide wording.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ec33f9aa2a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/desktop/src/main/cindy-brain/index.ts
处理 PR makecindy#1080 的两条 review 发现。

1) 随包对账写批准失败时 fail closed(index.ts)

走到 approveTrustedBundledInstall 的 catch 时,outcome.updated 那一支的内容目录
已被 swapInSeed 换成新种子字节,而旧 receipt 仍是授权事实。只 warn 后继续等于拿
旧批准跑新代码:新版删掉的 slot 仍被授予(fail-open)、版本与 trust 停在旧
revision、旧 revision 的技能快照还在生效。首装那一支本来就安全(没有旧 receipt →
legacy-unapproved)。

改为撤掉这条陈旧批准,fail closed 到 legacy-unapproved;随包插件下一轮启动对账
会重新补批准,自愈,不需要用户介入。撤批准本身再失败时升级为 log.error,那才是
需要人介入的状态。

2) 快照重建复现装入侧的 SKILL.md 门槛(ghostInstallReceipt.ts)

ensureSkillSnapshot 在快照缺失时从可变安装目录重建,此前直接 readFile,没有任何
stat / size 前置。后果不止把超大文件整份读进内存:把 frontmatter 写得与批准
manifest 一致、只让正文超限,setEnabled(true) 会返回 ok —— 启用这条路会批准一份
装入/更新永远不会接受的超大技能指令,两条路径的 size ceiling 不一致。

改为先 lstat 再读,用 GHOST_SKILL_MD_MAX_BYTES(与 GhostManager 装入校验同源,不
复制字面量)判定大小,并用 lstat 而非 stat 以拒绝软链与非普通文件,与本文件其余
位置一致。isFile() 那一半属于 defense-in-depth:软链方向在此之前已被
copyRegularDirectory 的 readdir 判定拦下,真正改变行为的是 size 判定。

回归覆盖(GhostManager.test.ts):

- holds the install-time SKILL.md size ceiling when rebuilding from mutable
  install bytes —— 去掉守卫必红(setEnabled 返回 ok);
- revoking approval fails the install closed, and a later bundled approval heals
  it —— 锁住第 1 条修复依赖的契约链:撤批准 → legacy-unapproved + enabled:false →
  setEnabled(true) 被 approval-required 拒 → 再次 approveTrustedBundledInstall 恢复。

reconcileBuiltinGhosts 是 index.ts 的私有函数且该模块会拉起整张 main 单例图,故
未给该 catch 分支本身写单测,只覆盖它依赖的契约。

另一条 review 反馈(prune 到 relink 之间的技能链接瞬时断链)核后未采纳:启动即
对账(index.ts registerGhostIpc)与 update 结尾的 onChanged 广播都会驱动对账自愈,
「悬空状态持续到下次启动」不成立;瞬时窗口是 pruneStaleSkillSnapshots 注释里写明
的有意取舍——让旧批准 revision 的技能继续对主 Agent 生效是更糟的方向。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 12:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts:132

  • GhostInstallReceiptStore.write() may leave behind an orphaned skill-snapshots/<id>/<revision> directory if ensureSkillSnapshot() succeeds but writing/renaming the receipt file fails (e.g. disk full / permission error). Because the receipt is the only “approved” fact source, this orphan snapshot will never be referenced but can accumulate on repeated failures. Consider cleaning up the newly-created snapshot target when the receipt commit fails, while avoiding deleting pre-existing snapshots during enable/disable toggles.
    const root = this.rootDir();
    await fs.promises.mkdir(root, { recursive: true });
    try {
      await this.ensureSkillSnapshot(receipt, options.skillSourceDir);
    } catch (error) {

Comment thread apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40fe328ae0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts Outdated
@Yuewei-LL

Copy link
Copy Markdown
Contributor Author

本轮增量 commit 只动了三个文件(+78 / -1):index.ts 的 fail-closed 收敛、 ghostInstallReceipt.ts 的 SKILL.md size/stat 守卫、GhostManager.test.ts 的两个用例。 刻意追加 commit 而不是 amend,方便 reviewer 只看增量。

验证:pnpm --filter desktop typecheck PASS;插件相关全部目录定向跑 122 files / 1676 tests PASS;pnpm test:unit 结果与 PR 描述里那条一致(本机 cindy-media 的 8 个 suite 因 better-sqlite3 原生模块在 Windows 下 collect 失败, 已在未改动的 origin/main 上复现,非本改动引入)。

处理 PR makecindy#108040fe328 的二次 review 发现(P1「Fail closed when a skill
snapshot is missing」)。

快照缺失时 setEnabled(id, true) 会从**可变安装目录**重建批准快照,而重建时唯一
的把关是 checkSkillMdConsistency —— 它只校验 frontmatter 的 name / description
(skillSlot.ts:61-81),随后 copyRegularDirectory 把整个技能目录原样拷成新的批准
快照。于是保持 frontmatter 不变、改写 SKILL.md 正文或往技能目录塞辅助文件,就能在
一次「启用」里把一份没人确认过的技能指令固化成已批准快照并全局挂链,交给主 Agent
以用户全部权限执行,全程没有新的权限确认。

这直接推翻了本仓规则里那条不变量:确认框看到的 SKILL.md 必须就是 Agent 之后读到
的那份。严格说不是本 PR 引入的回归(改之前技能链接直接指向可变安装目录,正文漂移
立刻生效,更糟),但是本 PR 声称堵上、实际没堵严的洞。

修法:

- receipt 新增必填字段 skillContentSha256(item.dir → sha256),由
  hashApprovedSkillContent 从**这次批准的内容目录**现算:逐 item 目录取排序后的
  相对路径 + 字节做规范化哈希,不跳过点文件(技能指令可引用目录内任意文件),
  非普通条目一律拒(判据与 copyRegularDirectory 同源);
- ensureSkillSnapshot 在从安装目录重建前重算并逐项比对,对不上即抛错 —— 启用被拒、
  插件保持停用,只能走完整重新确认,不允许就地「自愈」成新批准。内容未被改动的
  合法自愈场景不受影响;
- validateReceipt 把该字段做成必填且键集须与清单声明严格一致:留「字段缺失就跳过
  校验」的可选口子等于给漂移开一条绕过路径。receipt 格式尚未随任何版本发布,因此
  不需要兼容旧 receipt,schemaVersion 保持 1;
- install / update 的 receipt 创建从「解压前」移到「内容已落到 finalDir 之后」,
  指纹必须从真实内容目录现算而非凭空构造;approveTrustedBundledInstall 也把该字段
  纳入免写快路径的等值比较。

回归覆盖(GhostManager.test.ts):

- refuses to rebuild a snapshot when only the SKILL.md body drifted;
- refuses to rebuild a snapshot when a helper file was added to the skill
  directory;
  以上两条去掉指纹校验必红。
- still heals a deleted snapshot when the installed skill bytes are untouched
  —— 确认没把合法自愈一起堵死;
- invalidates a receipt whose skill content digests no longer match the manifest
  —— 必填字段缺失判 invalid、fail closed。

同步修正 docs/dev-rules/plugin-security-and-authoring.md:原文把快照的保证写得比
实现强,现改为写明 checkSkillMdConsistency 只管 frontmatter、重建必须逐字节对上
指纹、skillContentSha256 是运行期判据且必填;review 清单第 3 条增加对「任何从安装
目录取字节的路径」的检查项。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
@MagicLizi

Copy link
Copy Markdown
Contributor

⏸️ 本 PR 修改规则文档 plugin-security-and-authoring.md,触发 rules 签字门(审查判据变更),已开讨论 issue 等待 roster 确认。如需放行:在 PR 评论或讨论 issue 里回复同意,或摘掉 need-whitelist 标签。

讨论 issue:#1091

Comment thread apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated no new comments.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3387f85a7b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/desktop/src/main/cindy-brain/index.ts Outdated
处理 PR makecindy#1080 的一条 review 反馈(copyRegularDirectory 依赖
Dirent.isDirectory()/isFile(),若平台把软链/junction 报成 directory 就会跟进去,
把技能目录之外的字节拷进 Host 批准快照)。

先说实测结论:**当前平台上该利用不成立**。Node v22.14.0 / Windows 10 实测,
readdir(withFileTypes) 对 directory junction 与目录符号链接都报
isSymbolicLink=true、isDirectory=false(当前 libuv 先判 FILE_ATTRIBUTE_REPARSE_POINT
再判 FILE_ATTRIBUTE_DIRECTORY),因此两者本来就落进 else 分支被
"skill snapshot rejects non-regular entry" 拒掉。CI 用的同样是 Node 22。

但反馈指出的**脆弱性是真的**:这条安全判据建立在 libuv 的 dirent 类型位行为上,
而 Node 公开 API 并没有保证 reparse point 一定报成 link。判据不该依赖未文档化的
实现细节,尤其 Windows junction 创建不需要管理员权限、是本平台成本最低的一条把
外部字节拉进批准快照的路子。

因此本提交不修"现存漏洞",而是移除对 Dirent 类型位的依赖:

- copyRegularDirectory 与 hashApprovedSkillContent 两侧改为逐条 lstat,显式拒绝
  isSymbolicLink(),再用 lstat 结果判定 directory / file;
- 两侧判据保持同形 —— 否则指纹算的字节集与快照拷的字节集可能不是同一组。

hashApprovedDirectory(随包内容哈希)沿用同一 else-throw 结构、同样拒绝非普通
条目,且只作审计不参与运行授权判定,故未改动。

测试(GhostManager.test.ts)新增
refuses to follow a link planted inside the skill directory when rebuilding:
在技能目录里种一个 junction(Windows 无需管理员权限)指向外部目录,断言重建被拒、
快照不落地,且状态根内不出现任何来自技能目录之外的字节。

如实说明该用例的性质:它是**行为钉住**用例,不是回归用例 —— 已实测在把判据还原成
Dirent 类型位后它依然通过,因为当前 libuv 就把 junction 报成 link。它的价值在于
一旦将来 Node/libuv 改变 dirent 语义,这条会立刻变红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 13:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 45 changed files in this pull request and generated 1 comment.

Comment thread apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d627c0cb6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts Outdated
Comment thread apps/desktop/src/main/cindy-brain/forge.ts Outdated
@MagicLizi

Copy link
Copy Markdown
Contributor

@Yuewei-LL 👋 这个 PR 还有 8 条 review conversation 没 resolve(apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts / apps/desktop/src/main/cindy-brain/index.ts / apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts / apps/desktop/src/main/cindy-brain/forge.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。

如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。

处理 PR makecindy#1080 第 3 轮的两条 P1,以及随后两轮 review 的收尾项。

1) 快照重建的 check-then-use 窗口(greptile P1 SECURITY)

ensureSkillSnapshot 原来先用 hashApprovedSkillContent 读一遍源目录做校验,再用
copyRegularDirectory 又读一遍源目录做复制。两次读之间同权限进程可以换字节,复制
出来的快照因此可能不是被校验过的那一份,rename 之后它就成了「已批准」,主 Agent
经共享技能链接以用户全部权限执行。这个窗口在实现 3387f85 时就存在,是漏报。

改为先把字节复制进状态根的临时目录,再对 temp 里那份即将成为快照的字节做全部权威
校验(尺寸上限 → 指纹逐字节比对 → frontmatter 一致性),通过才 rename 就位。源目录
只剩「复制来源」一个角色,不再参与任何判定 —— 消除的是这一类,不是缩小窗口。复制前
仍留一次源目录尺寸预检,但注释写明它不是安全边界、只为早失败。

残留窗口如实记录:rename 之前 temp 位于状态根内、同权限进程仍可改写,本地校验能保证
「被校验的就是被复制的」,保证不到「rename 的就是被校验的」。

2) 撤销陈旧批准失败时的残留 fail-open(Codex P1)

40fe328 只在撤销成功时 fail closed,撤销失败走 log.error 后继续 —— 而撤销失败的
成因(状态根不可写)与最初写批准失败的成因是同一个,两个失败强相关。

把 fail-closed 契约收进 removeInstallApproval 本身:删得掉就删 receipt 与技能快照,
删不掉就转进程内隔离(untrustedApprovals)。此刻状态根已不可写,再指望往里写点什么
表达「已失效」并不可靠,内存标记是唯一还能用的机制。隔离由读批准状态的唯一入口
readApproval() 统一投影成 invalid,下一轮启动对账成功即自愈。

顺带把批准状态读取从四处(list / setEnabled / update 的 token 比对 / 随包免写快路径)
收敛成唯一入口,uninstall 也改走同一个撤销入口 —— 「同一判定散落多处」正是本 PR 前
几轮补一处漏一处的机制成因。撤销后补上 runtime / node broker / agent slot 三连熄灯:
「撤销后不再被授权运行」对已经跑起来的实例并不自动成立。

3) review 收尾项

- 技能内容指纹的尺寸上限校验移到算指纹之前:上限要在权威路径上真正生效,且不为一份
  注定被拒的字节先付整趟读取成本;
- hashApprovedSkillContent 与 hashApprovedDirectory 改流式喂 hash(摘要值不变,实测
  四类输入一致),技能目录里除 SKILL.md 之外的文件没有尺寸上限,整份读会被塞进来的
  超大辅助文件撑爆;
- hashApprovedDirectory 的条目判据对齐姊妹函数(逐条 lstat、显式拒 isSymbolicLink),
  它只是漂移检测器不作授权判据,对齐是为消除判据分叉;
- temp 侧 lstat 包领域错误,不再抛裸 ENOENT;
- logger 的 error 是可选方法,隔离信号退化到 warn,不因注入实现缺 error 就静默丢弃。

回归覆盖(GhostManager.test.ts,均逐一还原成修复前行为验证过精确变红):

- rejects bytes swapped after the hash check but before the snapshot copy finishes
- keeps an install unusable when a stale approval cannot be revoked
- applies the SKILL.md size ceiling to the bytes that actually landed in the snapshot

文档(plugin-security-and-authoring.md):§3 把「先复制进 temp、再校验 temp、通过才
rename」的顺序写成安全性质并禁止改回,补上撤销失败→进程内隔离这条通往 invalid 的
路径;§6 新增一条与「内容根字节可变」并列的独立缺口「批准状态根自身没有写保护」,
登记 temp 窗口与 receipt 无签名可伪造两个后果。

两处不在本 PR 修的毛边已写进 PR 描述交维护者拍板:随包插件被隔离时 UI 恢复提示是
死路(正确文案应是重启应用)、被熄灯的常驻随包插件同会话内不自动重启。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 30, 2026 15:31

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: 1 P1 (阻塞合并)

P1: index.ts:794 — reconcileBuiltinGhostsLocked 每次 tick 从可变安装目录重新推导 receipt.enabled,静默覆盖用户的显式禁用决策

reconcileBuiltinGhostsLocked()(L789–795)在每次 reconcile tick(启动、登录、locale 变更)时从 .disabled 标记文件重新计算 enabled 参数传给 approveTrustedBundledInstall()。在已批准且内容匹配的稳态分支里,代码执行 if (current.receipt.enabled !== enabled) { write({...current.receipt, enabled}) } —— 只因标记文件与 receipt 不一致就覆写 receipt。

后果:用户通过 setEnabled('x', false) 禁用内置插件(正确写入 receipt.enabled=false 并创建 .disabled)→ 外部因素移除 .disabled 文件(AV 隔离/恢复、同步冲突解析器、手动清理)→ 下次 reconcile 静默翻转 receipt 回 enabled=true → 插件恢复完整能力集(tool 暴露、message hook、卡片 slot、外部链接等),无弹窗、无重新授权、无审计记录

这恰好重新引入了本 PR 声明要消除的模式(PR body: "trust / enabled 一律只从 receipt 取,安装目录里那三样退化为旧版本兼容镜像")。reconcileBuiltinGhostsLocked 在仓库中零测试覆盖

建议修复:在 current.state === 'approved' && content-matches 分支中,保持 current.receipt.enabled 不变,不接受 marker-derived override;仅在引导全新 receipt 时(else 分支,尚无其他 truth source)才参考 .disabled 标记。


P2(不阻塞)

  1. forge.ts:600: Host-managed-root 守卫用 fs.promises.realpath 而非 .native() 变体;Windows junction 正确性依赖一次性手动验证而非 CI(已在 PR body 如实披露)。
  2. forge.ts:447: scaffoldGhostDir 的手动 ancestor-walk 与新抽取的 resolveThroughExistingAncestor() 逻辑重复。
  3. GhostManager.ts:1055: approveTrustedBundledInstall 无运行时断言强制其 builtin-only 使用边界(当前靠单一调用者纪律保证安全)。

对应第 6 轮 review(1 P1 + 3 P2)。

## P1:`.disabled` 镜像丢失会静默重新启用用户显式停用的插件

reviewer 描述的因果链逐环成立:setEnabled(false) 双写 receipt.enabled=false 与
`.disabled` 镜像 → 外部因素(AV 隔离恢复/同步冲突解析/手动清理)移除镜像文件 →
下一轮随包对账把 `!existsSync(.disabled)` 喂给 approveTrustedBundledInstall,稳态
分支只因"镜像读数 ≠ receipt"就覆写 receipt 回 enabled=true → 插件恢复完整能力集,
无确认、无审计。这正是本 PR 声称消除的"可变安装目录反向覆盖授权事实"。比 review
指出的更重一层:带 skill 槽的随包插件会随之重新挂进全局技能链,以用户全部权限执行。

**没有原样采用建议修法**("稳态分支保持 receipt.enabled 不变,只在引导全新 receipt
时参考镜像"),它有两个缺口:
- 它把"镜像说停用、receipt 说启用"也一起忽略了。那是旧客户端唯一能表达停用的方式
  (老版本只写镜像不写 receipt),PR 承诺过回滚兼容;仓库规则也写明收敛方向不对称:
  停用必须永远能成功。
- 它只堵稳态分支。随包更新走的"建全新 receipt"分支用同一个镜像推导 enabled,镜像在
  更新 tick 之前丢失,新 receipt 照样 enabled=true —— 同一个洞换条路。

实际修法:合并规则收进 approveTrustedBundledInstall 一处,对两个分支同时生效 ——
镜像读数**只往停用方向合并,不往启用方向翻**:
    enabled = prior === undefined ? marker : (marker && prior)
- 镜像说停用 → 一律照办(旧客户端停用生效,安全方向);
- 镜像说启用、receipt 说停用 → 保持停用(修掉 P1),并把 `.disabled` 补写回去,
  守住"回滚到旧客户端按镜像判启停"的降级承诺(写不进只 warn,receipt 仍是权威);
- 无 receipt → 按镜像(引导/改名停用态补挂不受影响);
- 重新启用只有用户显式 setEnabled(true) 一条路。
prior 直接读盘上 receipt 而非 readApproval 投影:隔离态 receipt 不作授权事实,但
"曾经停用"只用于往下拉,是 fail closed 方向 —— 否则"隔离 + 镜像同时丢失"的组合
仍会让自愈把插件带回启用。

行为代价如实说明:用户在旧客户端里启用(删镜像)后升回新客户端,插件保持停用、
需再点一次启用。方向安全、路径罕见,判为可接受。

## P2.3:approveTrustedBundledInstall 加 builtin-only 运行期断言

该入口不经用户确认就铸出批准,此前边界只靠"唯一调用者是随包对账"的纪律。新增注入
式 isTrustedBundledId(生产接线 = id 在随包种子清单里,listBuiltinSeedIds),不在
清单一律抛错。做成注入而非硬编码前缀:企业种子 id 不带 cindy- 前缀,清单才是事实。

## P2.2:scaffoldGhostDir 的手写祖先 walk 去重

与 resolveThroughExistingAncestor 是同一段逻辑的两份实现(差异只有错误处理形态),
改为共用 helper、调用点包 INTERNAL。同一判定散落多处正是本 PR 反复出问题的形态。

## P2.1:forge 的 realpath 统一 .native 变体

fs.promises.realpath 没有 native 变体,promisify(fs.realpath.native) 一次,workdir
钳制/受管根解析/打包源目录三处统一。此前判为"一致性问题而非洞"(最终比较侧
isPathInsideDir 已做 win32 大小写折叠),但解析器不该是全仓唯一的例外,统一后也
消掉了"两种实现是否在多层 junction 链上等价"这个未实测的疑问。

## 回归覆盖(前两条已实测:还原成旧合并规则后精确变红)

- keeps a receipt-pinned disable when the .disabled mirror was lost, and rewrites
  the mirror —— P1 主用例,兼断言镜像回写
- a bundled update keeps the receipt-pinned disable even when the marker was lost
  —— 钉住"更新分支共用同一条合并规则",只堵稳态分支时此用例红
- an old-client style .disabled marker still turns a bundled receipt off ——
  钉住非对称的另一半(停用方向必须照办),防止把修复做过头
- refuses to mint a bundled approval for an id outside the seed roster

reconcileBuiltinGhosts 本身仍无直测(私有函数 + 拉起整张 main 单例图,与
r3682452719 里说明的一致);上述用例直测它依赖的公开入口契约。

## 验证

pnpm --filter desktop typecheck PASS;vitest run src/main/cindy-brain 77 files /
1116 tests PASS;pnpm test:unit 16964 passed / 断言层 0 失败(8 个 collect 失败
仍全为本机 cindy-media / better-sqlite3 既有环境问题,上一轮的 review-plugin
flake 本轮未复现)。

Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 31, 2026 12:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 49 out of 50 changed files in this pull request and generated no new comments.

@Yuewei-LL

Copy link
Copy Markdown
Contributor Author

@MagicLizi 已修(commit ac6a812),P1 与三条 P2 全部处理。逐条说明,其中 P1 没有原样
采用建议修法,理由如下。

P1:确认成立,已修——但修法与建议不同

因果链我逐环核过,全部成立:setEnabled(false) 双写 receipt 与 .disabled 镜像 →
外部因素移除镜像 → 下一轮对账把 !existsSync(.disabled) 喂给
approveTrustedBundledInstall,稳态分支只因读数不一致就覆写 receipt 回 enabled=true。
补一层你没展开的后果:带 skill 槽的随包插件会随之重新挂进全局技能链,由主 Agent 以
用户全部权限执行——这比 tool 暴露/message hook 恢复更重。

建议修法("稳态分支保持 receipt.enabled 不变,只在引导全新 receipt 时参考镜像")有
两个缺口,所以没有照抄:

  1. 它把「镜像说停用、receipt 说启用」也一起忽略了。那是旧客户端唯一能表达停用的方式
    (老版本只写 .disabled 不写 receipt),PR 正文承诺过「回滚到旧客户端不出现批准态
    说停用、旧客户端却点亮的错位」,规则文档也写明收敛方向不对称:停用必须永远能成功
  2. 它只堵稳态分支。随包更新走的「建全新 receipt」else 分支用同一个镜像推导 enabled——
    镜像在更新 tick 之前丢失,新 receipt 照样 enabled=true,同一个洞换条路。

实际修法:合并规则收进 approveTrustedBundledInstall 一处、对两个分支同时生效——
镜像读数只往停用方向合并,不往启用方向翻

enabled = prior === undefined ? marker : (marker && prior)
  • 镜像说停用 → 一律照办(旧客户端停用生效,安全方向);
  • 镜像说启用、receipt 说停用 → 保持停用(修掉 P1),并把 .disabled 补写回去,守住
    旧客户端降级承诺(写不进只 warn,receipt 仍是权威);
  • 无 receipt → 按镜像(引导 / 改名停用态补挂不受影响);
  • 重新启用只有用户显式 setEnabled(true) 一条路。

prior 直接读盘上 receipt 而非 readApproval 投影:隔离态 receipt 不作授权事实,但
「曾经停用」只用于往下拉、是 fail closed 方向——否则「隔离 + 镜像同时丢失」的组合仍会
让自愈把插件带回启用。

行为代价如实说明:用户在旧客户端里启用(删镜像)后升回新客户端,插件保持停用、需再点
一次启用。方向安全、路径罕见,判为可接受;不认可的话这一半可以单独再议。

关于「reconcileBuiltinGhosts 零测试覆盖」:属实,此前在 r3682452719 也说明过成因
(私有函数 + index.ts 拉起整张 main 单例图)。这轮补的 4 条用例直测它依赖的公开入口
approveTrustedBundledInstall 的合并契约,前两条已实测还原成旧行为后精确变红:

  • keeps a receipt-pinned disable when the .disabled mirror was lost, and rewrites the mirror
  • a bundled update keeps the receipt-pinned disable even when the marker was lost
    ——钉住"更新分支共用同一条规则",只按建议堵稳态分支时此用例红
  • an old-client style .disabled marker still turns a bundled receipt off
    ——钉住非对称的另一半,防止把修复做过头
  • refuses to mint a bundled approval for an id outside the seed roster

一处小勘误:触发点是 startup / auth-change / legacy-recovery / restore 四个,不含
locale 变更(locale 只影响 list() 的本地化投影,不跑 reconcile)。不影响结论。

P2 三条都做了

  1. realpath 统一 .nativefs.promises.realpath 没有 native 变体,
    promisify(fs.realpath.native) 一次,workdir 钳制 / 受管根解析 / 打包源目录三处
    统一。此前我判它"一致性问题而非洞"(最终比较侧 isPathInsideDir 已做 win32 大小写
    折叠),但解析器不该是全仓唯一的例外,统一后也消掉了"两种实现在多层 junction 链上
    是否等价"这个未实测的疑问。
  2. scaffold 祖先 walk 去重:删掉手写份,与打包侧共用 resolveThroughExistingAncestor
    (差异只有错误处理形态,调用点包回 INTERNAL)。同一判定散落多处正是这条链路反复出
    问题的形态。
  3. builtin-only 运行期断言:新增注入式 isTrustedBundledId,生产接线 = id 必须在
    随包种子清单里(listBuiltinSeedIds),不在清单一律抛错。做成注入而非硬编码
    cindy- 前缀:企业种子 id 不带该前缀,种子清单才是事实源。

验证

pnpm --filter desktop typecheck PASS;vitest run src/main/cindy-brain 77 files /
1116 tests PASS;pnpm test:unit 16964 passed / 断言层 0 失败(8 个 collect 失败仍为
本机 cindy-media / better-sqlite3 既有环境问题,未改动的 origin/main 同样复现)。

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在「待签字 PR」面板确认放行。

@MagicLizi

Copy link
Copy Markdown
Contributor

@Yuewei-LL 👋 这个 PR 目前与 main 有合并冲突,auto-review 因此暂时跳过、没法继续审查 / 合并。

请在本地 merge 最新的 origin/main 解决冲突后推送;冲突解除后,下一轮 auto-review 会自动重新处理这个 PR。

@MagicLizi MagicLizi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查通过(heavy tier),零 P0/P1。安全扫描通过。

三项重点安全验证均独立确认:

  1. Receipt 伪造/重放:Renderer→Main 的确认 token 由 Main 从 live state 重新计算,不可重放;残留的 same-privilege 本地伪造已在 plugin-security-and-authoring.md 中显式声明为已知限制
  2. 路径穿越:所有 receipt/skill-snapshot 路径经 isValidGhostId/UUID 格式校验 + ghostContentTree.ts 逐段 lstat 解析(拒绝 symlink/junction);测试覆盖了双向包含攻击
  3. 迁移安全list() 按 content-directory 存在性枚举而非 receipt 存在性,孤立 receipt 不能复活已删插件;uninstall() 先删内容再 best-effort 清 receipt

额外确认:内容哈希长度前缀防碰撞、skill-snapshot digest 不匹配时自动断链、i18n 四语言键一致。

@MagicLizi
MagicLizi merged commit 890d4b3 into makecindy:main Jul 31, 2026
2 checks passed
@MagicLizi

Copy link
Copy Markdown
Contributor

主干代合并:冲突文件 docs/dev-rules/plugin-security-and-authoring.md(#1212 新增 confirmSlot 行与本 PR 新增 receipt 行相邻),合并后保留两者;typecheck 验证通过(41 个 @cindy/slack-hook-protocol 错误为 main 上预存)。

这个 receipt 把安装目录从授权事实里摘干净了——以前「就地改 ghost.json 绕权限 diff」那个洞真打实了,而且文档诚实登记了剩余两个 gap,下次改就不用重新考古。

MagicLizi added a commit that referenced this pull request Jul 31, 2026
#1080 的批准 receipt 改造把「无 receipt = 不构成运行授权」一次性作用到全部存量
安装,只给随包内置插件留了自动补批准的路,市场与本地安装没有 backfill 路径。
结果升级后所有非随包插件同时变成停用、必须逐个重新确认,本地包还要求重新提供
原始 .cindy 文件——包已丢失就无从恢复。安全方向是对的,落地方式把一次内部机制
升级变成了全量用户故障。

新增 plugin-security-and-authoring.md 第 5 节「存量插件兼容:升级必须无感」:

- 插件系统改动(批准状态 schema/指纹编码/manifest 校验/slot 形态/快照布局/
  安装根与状态根/.cindy 包格式/管子协议/内置 id)必须向下兼容;判据是用户
  升级后什么都不做时的可用性,不是代码路径没报错。
- 新增校验或必填字段默认自带迁移(backfill),不是自带拒绝——「老数据缺新字段」
  是升级前的正常历史状态,不是攻击证据。
- 迁移不得成为扩权或降级通道:只在从未有过 receipt/已判损坏时发生,权限集原样
  不扩权,来源记日志;迁移出的授权只等价于旧版本已给出的授权,不等价于一次新的
  用户确认。
- 自动做不到时四条兜底义务:能自动就别打扰用户、明确提示 + 一次性批量恢复入口、
  不丢凭证与偏好、留回滚余地(新状态被旧版本读到不判损坏)。
- 测试门槛:必须有基于旧布局 fixture 的升级用例,只测全新安装不算覆盖。
- 漏迁移 = P0,与 mobile 冷更同级,需把关人对该影响明确确认。

配套接线:AGENTS.md 触发条件、REVIEW.md 审阅口径、PR 模板风险勾选项与说明、
Review 清单 6.6、docs/README.md 与 docs/dev-rules/README.md 索引。原第 5–7 节
顺延为 6–8 节,交叉引用同步更新。

一并订正两处影响面表述:随包内置插件由 provisioning 逐字节对账后走
approveTrustedBundledInstall 自动补批准、不受影响,因此是「全部非随包插件」失效
而非「全部插件」;并补上本地包在原始 .cindy 丢失时不可恢复这一更重的后果。

测试:pnpm test:unit 全绿(exit 0;apps/desktop unit 另行单独复核 PASS 121.6s)。
本次改动只含 .md,不涉及任何 package 的 typecheck 面。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lizi <jiali@magiclizi.com>
MagicLizi added a commit that referenced this pull request Jul 31, 2026
…rity"

This reverts commit 890d4b3, reversing
changes made to b7c098253ae0f1a03f43b1a0e6b53ad1e35f5d0e.

回滚原因:违反 docs/dev-rules/plugin-security-and-authoring.md 第 5 节「存量插件
兼容:升级必须无感」红线(该节与本次回滚同批立规,见 b7ebb8f)。

#1080 把「无批准 receipt = 不构成运行授权」一次性作用到全部存量安装,只给随包内置
插件留了自动补批准的路(provisioning 逐字节对账后走 approveTrustedBundledInstall),
市场与本地安装没有 backfill 路径。实机结果:升级后全部非随包插件同时变成停用、开关
置灰、逐个挂上「需要重新确认权限」;市场包需手动点一次重装确认,本地包要求重新提供
原始 .cindy 文件——包已丢失即无从恢复。这正是 #1080「手工验证」一节自列为待补的第 1
条,合并前未实机跑过。

安全方向是对的:#636 的漏洞(就地改写安装目录,让更新确认框的权限 diff 以被改过的
现场为基线显示「无新增」,未经确认的 slot 因此拿到运行授权)真实存在。回滚后该漏洞
重新敞开,已在第 7 节登记为【待重做】并写明重做时必须一并带上第 5 节要求的迁移;
详细验收标准另见本次一并开出的 issue。

冲突处理(docs/dev-rules/plugin-security-and-authoring.md,按「回滚实现、保留规则」解):
- 第 3 节撤回 receipt / ghostContentTree / 强化版 skill 槽的描述,回到 #1080 之前
- 保留第 5 节存量插件兼容红线(b7ebb8f2 新立,正是用来约束重做)
- Review 清单撤回 #1080 的第 6/6.5 条,保留存量兼容条目并重排为 5–8;措辞从
  「receipt」泛化为「批准状态记录」,因为该机制当前已不存在
- 第 7 节原「未修复:存量安装没有 receipt backfill」改写为「待重做:授权事实与可变
  安装目录解耦(原 #636)」,写明漏洞当前敞开、以及重做时的迁移与恢复路径要求
- 第 5 节历史教训补上「已于 2026-08-01 回滚」

工作区里 AGENTS.md 与 REVIEW.md 另有他人未提交的改动,本次未纳入。

验证:pnpm test:unit 全绿(exit 0;apps/desktop unit PASS 113.5s;0 FAIL);
pnpm --filter desktop typecheck、pnpm --filter cindy-tools typecheck 均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lizi <jiali@magiclizi.com>
@MagicLizi

Copy link
Copy Markdown
Contributor

这个 PR 已在 main 上整体回滚(eb6556dd),原因与重做要求记在 #1243

简述:把「无批准 receipt = 不构成运行授权」一次性作用到全部存量安装,只给随包内置插件留了自动补批准的路(approveTrustedBundledInstall),市场与本地安装没有 backfill 路径。实机结果是升级后全部非随包插件同时停用、开关置灰、逐个挂上「需要重新确认权限」;市场包需手动点一次重装确认,本地包要求重新提供原始 .cindy 文件——包已丢失即无从恢复。这正是本 PR「手工验证」一节自列为待补的第 1 条,合并前没实机跑过。

安全方向是对的#636 的漏洞真实存在、现在随回滚重新敞开,已在 plugin-security-and-authoring.md 第 7 节登记为【待重做】。PR 描述里对这个代价的披露是到位的(分三档 + 加粗标注「刻意取舍,留给维护者拍板」+ 给了折中方案),合并方也有责任。需要重做的只是落地方式:自动修复(backfill)与向下兼容这两块没考虑到。

同批新立了第 5 节「存量插件兼容:升级必须无感」红线(b7ebb8f2):新增校验或必填字段默认自带迁移而不是自带拒绝,「老数据缺新字段」是升级前的正常历史状态、不是攻击证据;自动做不到时要有明确提示 + 一次性批量恢复入口;必须有基于旧布局 fixture 的升级用例。逐条验收标准列在 #1243,重做时按它对。

注:本想把 #1243 assign 给你,但 GitHub 拒绝(非本仓 collaborator),所以在这里 @ 一下 —— @Yuewei-LL

xxxxxccc added a commit to xxxxxccc/cindy that referenced this pull request Jul 31, 2026
…ycle-readiness

main 在 eb6556d 整体回滚了 makecindy#1080(插件批准态完整性),同时以 b7ebb8f 立了
plugin-security-and-authoring.md 第 5 节「存量插件兼容 = 红线」。冲突就是上一轮
合并时手工并入的那些行现在要被撤回,按 revert commit 自己的口径解:回滚实现、
保留规则。

## 冲突解决(3 处,全部撤回 makecindy#1080 引入的引用)

- installErrorKey.ts: 撤回 PRECONDITION_FAILED case(对应 i18n key
  approvalRequired 已随 revert 删除),保留本分支的 SETUP_STATUS_UNAVAILABLE
- GhostPluginDetailView.tsx: 开关撤回 needsReapproval 禁用条件,保留本分支的
  Tip 包裹 + aria key;版本号后的就绪徽章恢复无条件渲染
- GhostPluginPage.tsx: 回到 diffGhostPermissionItems,撤回
  expectedInstalledApproval;卡片撤回 needsReapproval,保留 useGhostReadiness

上一轮为了避免两个徽章并存而加的「未批准态不叠就绪徽章」也随之撤回——
needsReapproval 这个状态在 main 上已不存在。

## 第 5 节红线自查:存量插件影响 = 无

本链路唯一新增的落盘物是 ghost-credential-rejections.json(owner 作用域
userData 下的独立文件):

- 老版本 userData 里没有该文件 → ENOENT 按空账处理,已配置的存量插件判定
  不变、照旧可用,不要求重装/重新确认/重填凭证
- 读取不创建文件,未知字段忽略而不判损坏(留回滚余地)
- 生命周期投影全部是现有存储的现查读取(GhostManager 启用态、oauth 账号面、
  连接清单、kv、manifest),没有新增 schema、没有新增必填字段,因此不存在
  需要 backfill 的旧数据
- 未就绪插件是**降级暴露**(花名册保留、不派发工具 + 引导配置),不是停用:
  开关照旧可点、凭证与偏好不动。此前已配置好的插件升级后仍判 ready

补 3 条「从旧状态升级」用例(ghostCredentialRejections.test.ts):台账文件
不存在 → 空账且不被创建;无台账时折算为恒等变换、ready 不会降 required;
含未知字段的台账仍可读。

验证:desktop typecheck 通过;定向单测 src/main/cindy-brain、
src/main/mcp-integrations、src/main/plugin-market、renderer 插件相关
135 files / 1841 tests 全绿(本次新增 3 条后为 1844)。全量单测交 CI。

Signed-off-by: xxxcc <xxxcc123@126.com>
MagicLizi added a commit that referenced this pull request Jul 31, 2026
plugin-security-and-authoring.md 第 5 节已有「插件基座改动一律走白名单确认门」的
正文(随 eb6556d 一并落地),但两个入口还没接上:AGENTS.md 的触发条件与 REVIEW.md
的审阅口径都不提这道门,Agent 与 review 因此不会被引导去读它。本次补齐:

- AGENTS.md:在存量插件兼容红线后写明改到插件基座(运行时/沙箱、批准状态记录、
  能力 slot、打包与内容判据、manifest 契约、装入与权限确认 UI、已装列表投影)的 PR
  一律走确认门,需放行人明确 Approve,不看 diff 大小、不因「是 bugfix/纯技术改动」
  豁免。
- REVIEW.md:存量插件兼容条目补上「同时要走白名单确认门、命中就在评论里点名放行人」。

一并修 eb6556d 回滚留下的两处不一致:

- REVIEW.md 指的「Review 清单 6.6」已悬空——回滚撤回了 #1080 加的第 6/6.5 条,
  存量兼容条目重排为第 5 条,引用同步改正。
- 两处「批准 receipt」指向已被回滚删除的机制,泛化为「批准状态记录(receipt 一类)」,
  与 plugin-security-and-authoring.md 正文口径一致。全仓已复查无其它悬空引用。

验证:pnpm test:unit 全绿(exit 0;apps/desktop unit PASS 110.4s;0 FAIL)。本次改动
只含 .md,不涉及任何 package 的 typecheck 面。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lizi <jiali@magiclizi.com>
xxxxxccc added a commit to xxxxxccc/cindy that referenced this pull request Jul 31, 2026
本轮 4 条未 resolve review + 1 条折叠意见:

1. 租约跨实例失效(P1,Greptile + Codex)。`PluginMarketService` 每次操作都
   `new MarketSourceManager(...)`,而引用计数是实例字段 —— 安装 manager 持有
   的租约对刷新 manager 完全不可见,刷新照样删掉正在被打包读取的旧版本,上一轮
   加的引用计数跨操作等于没做。注册表(versionRefs / pendingDeletes /
   activeIncoming)改为**模块级、按绝对路径为键**:受保护的对象本来就是文件系统
   路径,按路径为键才是正确作用域;owner 隔离由 ownerScopedUserDataPath 编进
   cloneRoot 的路径自带。租约测试改为读取与刷新走**两个不同的 manager 实例**,
   与生产一致。

2. 激活后不得回收新版本(P2)。原 catch 覆盖了 activate + prune + store.update:
   元数据写入失败(磁盘满/只读/文件锁)时指针已指向 newDir、旧版本已被清理,
   却仍把 newDir 删掉 —— 留下指向不存在目录的指针,整个来源从列表消失。现在
   可回滚的清理只覆盖"落位 + 激活"这一段,且删除前用指针再核对一次;激活之后
   的失败只如实上报,绝不回收已生效的缓存。

3. 服务端安装入口同口径重算冲突(P2)。快照按"服务端目录 + 全部自定义来源"合并
   计数标 conflict 并禁用,但 detail() 传空重复集合把它恢复成可安装,install()
   也只统计服务端目录内部重名 —— 普通 UI 流程或直接 IPC 都能装上本该被冲突闸
   拒绝的服务端项并抢占 ghostId。抽出 crossSourceDuplicateGhostIds /
   assertSourceOwnsGhostId,服务端与自定义两条安装路径、两个详情入口共用同一
   口径,已拥有安装记录的来源保留所有权。

4. sparse 克隆不再留 detached HEAD(Copilot 折叠意见)。无显式 ref 时原先
   `checkout HEAD`,导致刷新的 `git pull --ff-only` 稳定失败并回落整仓重克隆 ——
   sparse 源每次刷新都全量 clone,快进路径实际是死代码。改为解析 origin/HEAD
   取默认分支并 checkout 该分支,保留跟踪关系;解析失败才退回 HEAD。

合并 upstream/main:upstream 已 revert makecindy#1080(批准 receipt 改造),因此
expectedInstalledApproval / ghostInstallApprovalToken /
diffInstalledGhostPermissionItems / forbiddenRootDirs /
resolveThroughExistingAncestor / onReapprove 全部不再存在,上一轮为并存而保留
的这些引用同步撤掉,自定义源安装的权限对比回到 diffGhostPermissionItems。

存量插件影响:无。改的是市场克隆缓存布局(versions/ + current 指针 + incoming/),
不触及已安装插件的包目录、批准状态与账本 schema;旧布局(槽目录直接是缓存)由
resolveCurrentVersionSync 自动迁移进版本目录,升级后用户无需任何操作,已装、
已批准、已启用的插件照旧可用。基于旧布局的升级用例见 sources-manager.test.ts
的 legacy 迁移用例。

验证:desktop main(plugin-market 149 全过)、renderer 6840 全过、typecheck、
check:i18n、check:i18n-glossary 通过。新增 5 个用例均验证过"去掉修复即失败"。
scriptRunnerPythonProtocol 与 makerSendToSessionOrdering 的 6 个失败在移除本次
改动后同样存在(来自合并进来的 upstream/main),与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>
@Yuewei-LL
Yuewei-LL deleted the fix/plugin-approved-state-integrity branch August 1, 2026 03:37
GaoWeiLiuXD pushed a commit to GaoWeiLiuXD/cindy that referenced this pull request Aug 1, 2026
* feat(desktop): 支持 Git/本地自定义插件市场源

- 新增 plugin-market/sources:来源解析(GitHub shorthand/Git URL/本地路径)、
  Git 克隆(稀疏检出/staging 原子替换/GIT_TERMINAL_PROMPT=0/错误分类)、
  Codex 兼容 marketplace.json 发现、sources.v1.json 持久化与 SourceManager
- 市场快照聚合自定义来源:合成 pluginId/releaseId 复用既有 detail/install/
  uninstall 与 update-available 机制,ghostId 跨源冲突全局判定(先装先得),
  服务端不可用时自定义市场仍可用
- 目录插件安装复用 forge 打包核心(新抽 packGhostDirToFile)与
  installOrUpdateMarketGhostPackage;自定义源不享受官方前缀豁免,
  安装前重读清单逐字比对防确认后篡改
- UI:筛选 tab 新增"自定义"(无源时隐藏,≥2 源分组小标题)、添加插件菜单
  新增"添加插件市场…"对话框(添加表单 + 源列表刷新/移除 + Git 前置检测),
  移除确认框层级提升至 z-10002 避免被双层 Dialog 遮挡
- 自定义来源的安全说明与安装确认文案如实区分(未经服务端 SHA-256 校验),
  4 语言 i18n 落地

Signed-off-by: xxxcc <xxxcc123@126.com>

* docs: add custom marketplace UI screenshot

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 修复自定义市场源 review 安全与正确性问题

- parse: 拒绝内嵌凭证的 Git URL(https 用户/密码、ssh 密码位),
  避免明文凭证写入 sources.v1.json 并回传 Renderer
- install: 自定义市场安装前比对用户审阅的完整 manifest(经 IPC
  expectedManifest 传递),保持 id/version 不变但篡改权限声明的
  ghost.json 将被 PRECONDITION_FAILED 拒绝
- discover: 用 realpath 校验插件目录真实路径仍在市场根目录内,
  市场内 symlink 指向外部的条目跳过
- sources: Git 源刷新失败后的整目录重克隆先在 staging 完成完整
  发现验证再替换旧缓存,远端损坏时保留上一次可用内容
- git: 普通 clone 后显式 checkout ref,--branch 无法检出裸 commit SHA
- forge: packGhostDirToFile 的最终写文件失败纳入 ForgePackResult
  结构化错误,不再以异常逃逸契约
- Renderer: 添加市场对话框识别反斜杠开头的 Windows UNC/绝对路径;
  移除市场失败改为与刷新一致的内联错误展示
- i18n: 新增 credentialsNotAllowed 错误文案(4 语言)

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 修复自定义市场源第二轮 review 正确性与并发问题

- forge: packGhostDir 的最终 writeFile 纳入 ForgePackResult 结构化
  错误,与 packGhostDirToFile 对齐,源码目录只读/磁盘满时返回
  { ok: false, errorCode: 'INTERNAL' } 而非 reject
- service: 自定义插件 install/uninstall 互斥键统一为规范化
  pluginId,消除两个窗口并发更新与卸载的竞态(误首装启用 /
  账本与文件状态颠倒)
- service: customToItem 让已拥有安装记录的来源豁免 duplicate
  conflict,重复 ghostId 只标未拥有安装的竞争来源,避免已安装
  插件被降格、失去市场更新入口(先安装者优先)
- sources: Git 源刷新统一 staging 化——复制现有缓存到 staging、
  在 staging 内快进并完整发现验证,成功后才原子替换旧缓存;
  修复快进成功但新内容损坏时原地覆盖有效缓存的问题
- sources: fetchMarketplace 支持注入工作目录(cwd),供 staging
  内快进使用;本地源刷新分支 lastSyncedAt 单次取值复用,避免
  持久化值与 UI 展示毫秒级不一致
- i18n: credentialsNotAllowed 文案缩进对齐同对象其它 key

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 修复自定义市场源第三轮 review 安全与正确性问题

- service: snapshot 在发现自定义市场前先捕获 owner 并绑定其
  作用域,跨 await 后用 generation 校验会话;账号切换期间不再
  按调用时 owner 现查 store/目录,直接降级为空并标记
  session-switching,避免账号 A 的插件数据泄漏给 B 的 Renderer
- sources: Git 源刷新的缓存交换改为可回滚——旧目录先原子改名
  为备份、staging 落位成功后删备份;rename 失败(Windows 文件锁/
  权限/瞬时 I/O)时从备份恢复 cloneDir,不再先删旧缓存再 rename
- parse: 稀疏路径拒绝以 - 开头的值(--stdin 会让 sparse-checkout
  读 stdin 直至超时);git sparse-checkout set 参数加 -- 终止选项
  解析作为双保险
- renderer: MarketplaceSourcesDialog / MarketplaceGuideDialog 改用
  显式 type-only import(CSSProperties / ReactNode),不再依赖 React
  UMD 全局命名空间

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 修复自定义市场源第四轮 review 账户绑定与回滚问题

- service: 自定义安装打包完成后、装出运行前经 beforeCommit 重新
  校验最初捕获的 owner,避免打包期间账号切换把 A 审阅的插件装进
  当前账户 B 的运行时(installCustomMarketPlugin 新增可选校验钩)
- service: listSources 返回前校验会话 generation,异步发现期间
  账号切换时拒绝,不再把 A 的私有仓库 URL/本地路径发给 B 的 Renderer
- service: 服务端 toItem 与自定义侧对齐,已拥有安装记录的来源豁免
  duplicate conflict,先装好的服务端插件不再被同名自定义来源降格
- sources: 缓存交换回滚失败不再吞错——staging 落位失败且旧缓存恢复
  rename 也失败时抛出含备份路径的 INTERNAL 错误,便于定位手动恢复,
  不再让旧缓存静默遗失在随机备份目录

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 自定义市场账户绑定收口、安装防篡改与缓存交换自愈

- service: 抽 runForOwner 统一闸,所有把市场数据返回 Renderer 的
  owner-bound 读/管理路径(listSources/addSource/removeSource/
  refreshSource/detail/customDetail)在返回前强制 generation 校验,
  账号在 await 间隙漂移即拒绝,不再逐路径漏加;snapshot 聚合返回前
  补最终校验。写路径 install/uninstall 保留「owner 变更后仍完成
  已验证安装、账本归最初捕获者」的既有语义,不套用返回前拒绝。
- install: 安装防篡改改比实际打进包的 manifest(packed.manifest)
  而非打包前磁盘上的 ghost.json,堵住前置比对通过后、打包读取
  文件前目录被改(保持 id/version 却新增权限声明)的窗口;冗余的
  前置逐字比对移除,前置仅保留清单合法性与保留 id 快速失败。
- sources: Git 缓存交换改用固定备份名(cloneDir.backup)并在
  refreshSource/discoverSource/discoverAll 入口自愈——cloneDir 缺失
  但备份存在时自动拉回固定路径,连续交换/恢复失败不再让有效缓存
  脱离 cloneDir、来源持续不可用;交换+恢复连续失败的诊断(含 backup
  绝对路径与原始 FS 错误)只进 main 日志,IPC 返回不含内部路径的
  通用错误。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 自定义市场快照出口校验、原子写回滚与缓存交换并发防护

- service: snapshot 服务端 listAll 失败分支在返回前补 generation
  校验——切号导致服务端请求失效时,不再把按旧账号发现的自定义
  市场项返回当前会话;并标注捕获 owner 后每个 return 出口都须过闸
- utils: 新增 atomicWriteFileSync 可回滚原子写(temp + rename;
  Windows EPERM/EEXIST 兜底改备份交换,先改名 .bak 再落位、失败从
  .bak 恢复),store.ts(sources.v1.json)与 ledger.ts(ledger.v1.json)
  统一换用,修掉兜底删目标后二次 rename 失败导致配置全丢的缺陷
- sources: Git 缓存交换加 .swapping 文件哨兵——交换瞬态(cloneDir
  已改名 .backup、staging 未落位)与遗留故障同形,并发 discoverAll /
  snapshot 的 recoverCloneCache 见新哨兵判定为进行中不抢占拉回,
  仅无哨兵或哨兵过期(进程崩溃残留)才自愈,避免顶掉 staging 落位
  目标并使回滚失效

Signed-off-by: xxxcc <xxxcc123@126.com>

* refactor(desktop): 自定义市场缓存改为版本目录+指针,并修复凭证与原子写

缓存交换反复出问题的根因是"原地交换固定路径 + 事后补救"的思路本身:
cloneDir 被搬来搬去,任何时刻都可能不存在或被占用,并发与失败都无法
干净处理。改为版本目录 + 当前指针,从结构上消除这一整类问题。

- sources: Git 缓存改为 sources/<slug>/versions/<version>/ + current
  指针文件。刷新在新版本目录完成克隆/快进与完整发现验证,通过后经
  可回滚原子写切换 current 指针,再清理旧版本;读取方永远经指针解析
  到完整版本目录,不存在"固定路径被 rename"的瞬态,不再需要备份交换/
  哨兵/自愈。旧布局(槽目录直接是缓存)首次读取自动迁移进版本目录。
- sources: 指针切换与删旧版本之间的 TOCTOU——读取方在版本目录放
  .reading 标记,刷新方删旧版时跳过有活跃读者的目录,避免误删在读者。
- parse: 拒绝带查询参数的 Git URL(?access_token=SECRET 等签名/令牌
  会明文持久化并回传 Renderer),引导走 credential helper / SSH。
- atomicWriteFile: 写入前若主文件缺失且 .bak 存在,先恢复 .bak 再写,
  避免把缺失主文件读成空后覆盖唯一快照(实现注释承诺的自愈)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* refactor(desktop): 自定义市场缓存读取无锁化+延迟清理,拒绝明文 git:// 协议

刷新方不再主动删除旧版本目录,只做原子切换 current 指针;历史版本
统一由下次刷新成功后的延迟清理移除。读取路径(listSources/快照/安装
打包)因此零并发保护,消除了解析→使用窗口内误删在读者、以及 .reading
标记被 fs.cp 复制进新版本导致的泄漏与磁盘无界增长。

同时收紧 Git 传输:parse 拒绝无认证无加密的明文 git://,仅允许
https/ssh/git@。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 缓存清理引用保护、读侧备份恢复与安装冲突重算

自定义市场第 N 轮 review 的三条未 resolve 意见,按同一批修复:

1. 缓存清理会抽走在读者(P2)。刷新切完 current 指针后立即删除全部非 current
   版本,包括"刚被切下来、正被发现/详情/安装打包使用"的那一代。改为进程内
   引用计数保护:resolveCurrentVersionSync 全同步,acquireCurrentVersion 在同
   一个同步块内解析指针并登记引用,中间无 await —— 不存在"解析到登记"的窗口
   (文件系统标记做不到这点,标记还会被 fs.cp 复制进新版本)。清理只删引用数
   为 0 的目录,仍被引用的推迟到最后一个引用释放。自定义源安装改走
   withDiscoveredSource,租约一直持到 .cindy 打包结束。

   顺带修同类缺陷:git clone 会在目标同级建 `<dest>.staging-*`,原先目标在
   versions/ 内,并发刷新的清理会删掉别人正在写入的 staging 目录。改为在途
   工作一律落在 versions/ 之外的 incoming/,验证通过才 rename 进 versions/,
   versions/ 只承载完整版本;incoming/ 残骸由下次刷新按在途集合前缀清理。

2. 备份只在写侧恢复,数据仍会永久丢失(P1)。主文件缺失时 ledger.read() /
   MarketSourceStore.read() 先把它读成空数据,调用方拿空数据发起写入,写侧
   刚恢复的 .bak 随即被空快照覆盖。新增 readAtomicFileSync,在读取入口恢复
   .bak 再解析,两个 reader 与 current 指针读取统一走它。

3. 安装入口不重算 ghostId 冲突(P1)。列表按"服务端目录 + 全部自定义来源"
   合并计数标 conflict 并禁用,但安装只在选定来源内查插件:不可信 Renderer
   直接调用 IPC,或详情打开后另一窗口添加了同 ghostId 的来源,都能把标记为
   冲突的插件装进来。安装在产生副作用前用同一口径重算所有权,服务端不可用
   时降级为仅自定义来源。

合并 upstream/main 时把 expectedManifest 与新的 expectedInstalledApproval
并存,自定义源安装同步采用 diffInstalledGhostPermissionItems(不信任可变的
live manifest 作为已批准基线)。

验证:desktop main 1305 + renderer 6854 测试、typecheck、check:i18n、
check:i18n-glossary 全过。新增用例均验证过"去掉修复即失败"。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 租约注册表跨实例共享、激活后不回收版本、安装冲突同口径

本轮 4 条未 resolve review + 1 条折叠意见:

1. 租约跨实例失效(P1,Greptile + Codex)。`PluginMarketService` 每次操作都
   `new MarketSourceManager(...)`,而引用计数是实例字段 —— 安装 manager 持有
   的租约对刷新 manager 完全不可见,刷新照样删掉正在被打包读取的旧版本,上一轮
   加的引用计数跨操作等于没做。注册表(versionRefs / pendingDeletes /
   activeIncoming)改为**模块级、按绝对路径为键**:受保护的对象本来就是文件系统
   路径,按路径为键才是正确作用域;owner 隔离由 ownerScopedUserDataPath 编进
   cloneRoot 的路径自带。租约测试改为读取与刷新走**两个不同的 manager 实例**,
   与生产一致。

2. 激活后不得回收新版本(P2)。原 catch 覆盖了 activate + prune + store.update:
   元数据写入失败(磁盘满/只读/文件锁)时指针已指向 newDir、旧版本已被清理,
   却仍把 newDir 删掉 —— 留下指向不存在目录的指针,整个来源从列表消失。现在
   可回滚的清理只覆盖"落位 + 激活"这一段,且删除前用指针再核对一次;激活之后
   的失败只如实上报,绝不回收已生效的缓存。

3. 服务端安装入口同口径重算冲突(P2)。快照按"服务端目录 + 全部自定义来源"合并
   计数标 conflict 并禁用,但 detail() 传空重复集合把它恢复成可安装,install()
   也只统计服务端目录内部重名 —— 普通 UI 流程或直接 IPC 都能装上本该被冲突闸
   拒绝的服务端项并抢占 ghostId。抽出 crossSourceDuplicateGhostIds /
   assertSourceOwnsGhostId,服务端与自定义两条安装路径、两个详情入口共用同一
   口径,已拥有安装记录的来源保留所有权。

4. sparse 克隆不再留 detached HEAD(Copilot 折叠意见)。无显式 ref 时原先
   `checkout HEAD`,导致刷新的 `git pull --ff-only` 稳定失败并回落整仓重克隆 ——
   sparse 源每次刷新都全量 clone,快进路径实际是死代码。改为解析 origin/HEAD
   取默认分支并 checkout 该分支,保留跟踪关系;解析失败才退回 HEAD。

合并 upstream/main:upstream 已 revert makecindy#1080(批准 receipt 改造),因此
expectedInstalledApproval / ghostInstallApprovalToken /
diffInstalledGhostPermissionItems / forbiddenRootDirs /
resolveThroughExistingAncestor / onReapprove 全部不再存在,上一轮为并存而保留
的这些引用同步撤掉,自定义源安装的权限对比回到 diffGhostPermissionItems。

存量插件影响:无。改的是市场克隆缓存布局(versions/ + current 指针 + incoming/),
不触及已安装插件的包目录、批准状态与账本 schema;旧布局(槽目录直接是缓存)由
resolveCurrentVersionSync 自动迁移进版本目录,升级后用户无需任何操作,已装、
已批准、已启用的插件照旧可用。基于旧布局的升级用例见 sources-manager.test.ts
的 legacy 迁移用例。

验证:desktop main(plugin-market 149 全过)、renderer 6840 全过、typecheck、
check:i18n、check:i18n-glossary 通过。新增 5 个用例均验证过"去掉修复即失败"。
scriptRunnerPythonProtocol 与 makerSendToSessionOrdering 的 6 个失败在移除本次
改动后同样存在(来自合并进来的 upstream/main),与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>

* refactor(desktop): 市场缓存删除收口到唯一入口,结构上排除误删在读路径

Greptile 这轮点的是 `removeSource` 整槽递归删绕过租约(并发的详情发现/快照/安装
打包正持有槽内版本目录时会读到一半 ENOENT)。这条成立,但更该修的是它反复出现的
形态:清点下来 `sources/index.ts` 有 **9 处删除点,只有 3 处查了租约**。租约是
"可选的",每加一个删除点就得记得查一次,所以每轮 review 都能再找出一个漏掉的
(交换旧目录 → 清理历史版本 → 清理暂存目录 → 移除来源 → 失败回滚)。这次不补第
7 处,改成让这类错误无法再写出来。

**收口**:新增 `sources/cacheLease.ts`,作为市场缓存路径的租约登记与**唯一删除
入口**。判据只有一条——目标路径与任何持有中的租约**在任一方向上重叠**就不删,
推迟到最后一个相关租约释放,执行前再过一次 `skipIf`(例如"该版本又成了 current"
就放弃)。三种历史事故都落在这一条里:
- 清理删掉正在被读的版本目录          → 租约 === 目标
- 移除来源整槽递归删而槽内版本在读    → 租约在目标之下(本轮 Greptile)
- 清理删掉 git 的 `<dest>.staging-*`  → 目标在租约"之下"(字符串前缀)

`sources/index.ts` 的 9 处删除点全部改走 `removeCacheDir` → `removeCachePath`,
文件内不再出现任何 `fs.rm`;各调用点的租约判断随之删掉(净减 120 行)。附带把
"快进失败后清理暂存目录再复用"改成**用另一个全新暂存目录**——清理现在要过守卫、
可能被推迟,复用会让 clone 写进脏目录;暂存回收统一到方法末尾一处。

**结构门禁**:新增 `sources-cache-lease.test.ts`,除守卫本身的行为用例外,断言
`sources/index.ts` **不出现任何直接文件系统删除**。行为用例只能覆盖已知路径,这条
门禁才是防止下一轮再冒出一个绕过点的东西——已实测:把 `removeSource` 退回裸删,
门禁与新增的行为用例同时失败(后者精确复现 Greptile 描述的 ENOENT)。

验证:desktop main 1264(plugin-market 157)全过、typecheck、check:i18n、
check:i18n-glossary 通过。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 备份恢复失败拒绝写入、Git stderr 路径脱敏、指南示例注释本地化

Codex 针对 4e33fb6 的 3 条 P1(第 4 条 removeSource 与安装并发已在 188d5bb
的删除收口中修掉):

1. `.bak` 恢复失败被当成恢复成功(数据永久丢失)。`restoreBackupIfMainMissing`
   的 catch 吞掉 rename 失败后仍返回 true:读取方据此读成空数据 → 调用方拿空数据
   发起写入 → 主文件出现 → **下一次写入把仍然有效的 .bak 当陈旧残留删掉**。改为
   区分"发现备份"与"恢复成功",恢复不了就抛 `AtomicBackupUnrecoverableError`,
   读写两侧一致拒绝继续;两个 reader 的 catch-all 显式放行该错误,不再降级成空
   账本/空来源表。宁可这次操作失败让上层看见,也不能把唯一副本换成派生自空数据
   的内容。

2. Git stderr 把宿主绝对路径泄露给 Renderer。原 sanitizer 只替换已知缓存路径与
   URL 内嵌凭证,而 OpenSSH、credential helper、杀毒钩子会往 stderr 写
   `/Users/<name>/.ssh/...`、`C:\Users\<name>\...`、`~/.gitconfig` —— 用户名与
   宿主目录结构不该经 IPC 到达 Renderer。新增通用绝对路径脱敏(POSIX 至少两段、
   Windows 盘符与 UNC、`~/` 开头),完整原文仍只留在 main 日志。

3. 指南结构树里的解释性注释硬编码中文。英/日/韩界面下展示与复制出来的内容都夹着
   "清单/插件身份卡/插件入口"。三个标签改为 locale key(四语言齐备),展示与复制
   共用同一份已本地化的树;路径本身仍不翻译,路径列宽固定所以箭头对齐不受标签
   长度影响。

验证:desktop main 1267、renderer 6842 全过;typecheck、check:i18n(6250 key
四语言一致)、check:i18n-glossary 通过。新增 4 个用例逐个验证过"退回修复即失败"。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 延迟删除再核对、默认安装与提交点冲突复核、URL 令牌与短路径脱敏

本轮 7 条 review 一次性修完(按要求先攒完再提交,不边提交边改):

1. 延迟整槽删除会删掉重新添加的来源(Greptile + Codex,**我上一个 commit 引入的
   回归**)。删除收口后 `removeSource` 的整槽删除会因租约推迟,而用户完全可以在
   推迟期间移除后立刻重新添加同名同源——槽路径只由 (name, source) 派生,同一个槽
   被复用并激活了新版本;那笔延迟删除执行时不核对,会把刚添加成功的缓存连 current
   指针一起删掉。新增 `slotIsConfigured` 作为执行前的再核对,`removeSource` 与
   `commitDiscoveredSource` 两处整槽删除都带上;按"槽是否仍被配置占用"判定,同时
   覆盖重新添加同源(同槽,放弃删除)与异源(异槽,旧槽照常回收)。

2. 默认安装先于跨来源冲突判定(Codex P1)。`applyDefaultInstalls()` 只按服务端目录
   判重,与自定义来源同 ghostId 的 defaultInstall 项会被自动下载、安装、启用,静默
   抢占所有权,而列表随后才把它标成 conflict——既定事实已经发生。冲突集合改为在
   默认安装**之前**算出并传入,命中的默认项直接跳过。

3. 所有权检查与提交之间仍有窗口(Codex P1)。入口检查之后,服务端安装还要下载数
   分钟、自定义安装还要异步打包,期间另一窗口可经独立的 market-sources 互斥键添加
   声明同一 ghostId 的来源。改为在真正改动 Ghost 运行时之前再算一次:服务端路径经
   `installDetail` 新增的 `recheckOwnership`(复用已拉到的 catalog,不多打网络
   请求),自定义路径经已可 await 的 `beforeCommit`。

4. `ssh://` 与 scp 形态未拒绝 query/fragment(Copilot)。`ssh://host/repo?token=`、
   `git@host:repo.git#sig=` 都能把令牌持久化进来源配置并在 UI 摘要露出;https 也漏了
   fragment。三种形态一并拒绝。

5. 短 POSIX 路径漏脱敏(Codex P1)。上一版正则要求两个带斜杠的段,实际只盖到三段
   及以上,`/home/alice`、`/tmp/foo` 原样回传 Renderer。改为一段起即覆盖,并先把
   URL 摘出占位再还原,保住用户自己输入的仓库地址可读。

6. 切到本地来源后仍提交 Git 专属字段(Codex P1)。用户先按 Git 源填了 ref/稀疏路径
   再改成本地目录时,那两个输入框已禁用、他无法自己清空,提交后被 Main 以
   REF_NOT_ALLOWED_FOR_LOCAL / SPARSE_NOT_ALLOWED_FOR_LOCAL 拒绝,成为死结。
   组装 payload 时按来源类型忽略这两个字段。

存量插件影响:无。只改市场缓存清理时机、安装期冲突判定、错误详情脱敏与来源输入
校验,不触及已安装插件的包目录、批准状态或账本 schema。

验证:desktop main 11038 通过 / renderer 6844 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 9 个用例**逐个验证过"退回修复即失败"**(其中 A、C
两条第一版没有牙齿——A 被 addSource 另排的带守卫删除掩盖,C 的竞争来源加得太早被
入口检查拦住——已改成直接写回配置与"第一次读取来源表之后才注入"两种精确时序)。
`makerSendToSessionOrdering` 与 `scriptRunnerPythonProtocol` 的 6 个失败在移除本次
改动后同样存在(来自合并进来的 upstream/main),已用 stash 取基线确认,与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 非 ENOENT 读取错误上抛、在途删除可等待、切号清空来源摘要

轮询到 Codex 3 条 P1,一次性修完(提交前又查过一次 review,无更新):

1. 非 ENOENT 读取错误被当成"文件不存在"(P1,数据丢失)。`readAtomicFileSync` 的
   catch 无条件返回 null:`sources.v1.json` / `ledger.v1.json` 明明在,只是被
   Windows 文件锁、权限或瞬时 I/O 挡住时,两个调用方都会解释成空状态,同一次
   添加/更新/账本写入随即用空状态派生的快照覆盖原文件,其余来源与安装记录永久
   丢失。现在只有 ENOENT 算"不存在",其余读取错误一律上抛。
   两个 reader 相应重构:读取与解析分开——IO 错误(含备份救不回来)直接上抛,
   只有"内容确实不是合法 JSON"才降级重建。原先那个 catch-all 会把新抛的错重新
   吞成空数据,等于白改。

2. 在途删除无法等待(P1,**cacheLease 的残留漏洞,我引入的**)。`drainDeferred`
   里是 `void fs.promises.rm(...)` 发射即忘,且队列项在 rm 完成前就被移除:
   `skipIf` 只在启动前核对一次,一旦 rm 开始跑就再也拦不住。移除来源后立刻重新
   添加同名同源时,旧槽删除可能正在途中,新版本落进同一个槽后被它顺手带走,
   留下有效配置但来源持续报缓存缺失。新增 `inFlight` 登记与
   `settleCachePathRemovals(path)`,复用槽的两个入口(添加、刷新)在写入前等待与
   该路径重叠的在途删除结束。

3. 切号后仍展示上一账号的来源摘要(P1,隐私)。对话框保持打开时切号,加载 effect
   不因 owner 变化重跑,读取失败时还明确保留旧列表;来源管理子对话框会继续把账号
   A 的私有仓库 URL 与本地绝对路径展示给账号 B。Main 的返回前 generation 校验只
   能拒掉新请求,撤不回 Renderer 已缓存的数据 —— 改为把 mode/dataOwnerId 作为
   作用域键,一变就立刻清空来源状态、关掉子对话框,再按新账号重载。

存量插件影响:无。只改本地状态文件的读取错误语义、缓存删除的等待时序与对话框的
账号作用域,不触及已安装插件的包目录、批准状态或账本 schema。

验证:desktop main 11040 通过 / renderer 6845 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 3 个用例均验证过"退回修复即失败";其中在途删除
那条第一版没有牙齿(最后等待 20ms 比模拟的 rm 40ms 还短,删除尚未落地就断言完了),
已把等待改到长于 rm 耗时。`makerSendToSessionOrdering` 与
`scriptRunnerPythonProtocol` 的 6 个失败为合并进来的 upstream/main 既有失败,
已用 stash 取基线确认,与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 槽复用持租约、指针可从备份恢复、清单拒穿越、提交段共享来源锁

轮询到 6 条 review,先修其中 5 条(第 6 条见下,需要产品决策才动):

1. 复用缓存槽期间尚未启动的延迟删除仍会启动(P1,**同一族的第三次,我的**)。
   `settleCachePathRemovals` 只能等到**已登记在 inFlight** 的删除;移除来源留下的
   那笔可能还躺在 deferred 里,会在 settle 返回后、`store.add` 之前因旧租约释放而
   启动 —— 那时 `slotIsConfigured()` 仍是 false,于是它与紧随的 mkdir/rename 并发,
   把刚重建的槽删掉。改为**整个复用段(清残骸 → 落位 → 切指针 → 写配置)对整个槽
   持租约**:守卫因此无法启动这笔删除;配置在释放槽租约之前写入,释放时 skipIf
   直接否决它,中间没有空档。

2. current 指针无法从备份恢复(P1)。指针也是原子写维护的文件,备份交换连续失败时
   唯一有效的那份留在 `current.bak`,而解析只看主文件,于是把仍有完整版本的缓存判成
   缺失,整个来源报 market root missing。改为经 `readAtomicFileSync` 读取(它会先
   恢复备份)。指针读不出来仍按"无可用缓存"处理——缓存可重建,与 ledger/来源配置
   不同,不存在"用空数据覆盖用户资产"的风险。

3. 清单读取跟随 symlink 可读到市场根目录外(Copilot)。恶意市场可以把
   `.agents/plugins/marketplace.json` 做成指向根目录外的链接,借宿主之手读任意路径。
   改为与 `resolvePluginDir` 同一套判据:realpath 后必须仍在市场根内,否则拒绝。

4. 安装提交段与来源变更缺互斥(P1)。`beforeCommit` 复核通过后,
   `installOrUpdateMarketGhostPackage` 还要先 await 包检查才开始改动运行时,那段
   时间另一窗口仍能添加声明同一 ghostId 的来源,复核结论在落位前过期。新增
   `withCommitLock`,把"复核 + 落位"整段放进与来源增删共享的 `market-sources` 锁;
   服务端路径同样把 recheck + 落位包进该锁。与按 pluginId 的安装锁嵌套无环,
   不会死锁。

5. 切号后移除确认框仍存活(P1,隐私/误删)。父对话框关闭时 `pendingRemove` 不清空,
   用户确认会对**当前账号**执行 removeSource;若新账号恰有同名来源,就会在仍显示旧
   账号来源名的确认框里误删新账号的配置。改为父级一关就清空待移除项与忙碌态,
   并让确认框的 open 受父级约束。

**未处理(需要决策)**:`ledger.ts` 的 `source` 枚举新增 `git-market`/`local-market`
后,回退到旧版本会被 `validRecord()` 过滤并重写落盘,自定义安装的溯源永久丢失
(再升级后该插件被投影成占用 ghostId 的本地冲突项、无法从原市场更新)。这命中新立的
存量兼容红线的**降级方向**,有多个可选设计(独立存储 / 旧版无损保留的编码),取舍
要产品定,不适合我单方面选。已在 thread 里说明现状与两个方案。

验证:desktop main 11045 通过 / renderer 6845 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 5 个用例逐个验证过"退回修复即失败";其中槽复用那条
第一版只验证了 cacheLease 的机制、没验证调用点,已改成用 `fs.promises.rename` 做
时序钩子精确命中"settle 之后、store.add 之前"的窗口。
`makerSendToSessionOrdering` 与 `scriptRunnerPythonProtocol` 的 6 个失败为合并进来的
upstream/main 既有失败,与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 自定义溯源独立账本、来源指纹所有权、提交点来源核验、指针 .bak 补盲

本轮 5 条 review 一次性修完(方案 A 经产品确认):

1. 自定义安装溯源移入独立账本(P1,存量兼容红线·降级方向,方案 A)。旧版
   `validRecord()` 是封闭枚举,`git-market`/`local-market` 记录在降级后的任意一次
   写入中被过滤并重写落盘,溯源永久丢失,再升级后插件被投影成"占用 ghostId 的
   本地冲突项"。现在自定义溯源写入 `custom-ledger.v1.json`(与主账本同目录、同
   owner 作用域),旧版本不认识也不会触碰它;主账本只出现旧版认识的 source。
   `PluginMarketLedger` 对外 API 不变,读取合并两个文件;早期开发版混写进主账本的
   存量在任意一次写入时按 source 自动归位。

2. 来源指纹纳入所有权(P1,安全)。市场名是 marketplace.json 自报的、可复用:
   移除来源 A 后添加同名来源 B,`customMarketPluginId` 完全相同,所有权只锚
   pluginId 会让无关甚至恶意仓库借同名市场"更新"A 装出来的插件。新增
   `marketSourceKey(source)`(与 sourcesEqual / marketCloneSlug 同一套判定维度,
   后者改为复用同一定义),自定义安装记录写入 `sourceKey`,customInstall 与列表
   投影(customToItem)的所有权判定都要求 pluginId 与指纹**同时**对上;指纹存放在
   独立账本里,不受旧版 schema 约束。

3. 提交点确认来源仍在(P1)。移除来源会先拿 SOURCE_MUTATION_KEY 删配置,租约只
   保住了目录字节;`assertSourceOwnsGhostId` 只查重复 ghostId,从不确认
   ref.marketName 仍指向最初发现的来源——没有竞争项时安装照样落位,写下一条没有
   对应来源的孤儿账本记录。`beforeCommit`(在提交锁内)新增核验:来源必须仍存在
   且指纹与入口发现时一致,否则 PRECONDITION_FAILED。

4+5. 指针读取的两处 `.bak` 盲区(Copilot,防御纵深)。`isCurrentVersionDir` 与
   `pruneStaleVersions` 仍在裸读 current 主文件:Windows 备份交换失败后唯一有效
   指针留在 current.bak 时,前者把当前生效版本误判成"非 current"、让推迟删除把它
   删掉,后者把"有 current"误判成"无"。都改走 `readAtomicFileSync`;读不出指针时
   fail-safe——isCurrentVersionDir 按"是 current"处理(宁可不删),prune 直接放弃
   本轮清理。

存量插件影响:无(本修复正是为守住降级方向的存量红线)。自定义市场特性尚未随
任何版本发布,`custom-ledger.v1.json` 是新文件;主账本 schema 与旧版本完全一致。

验证:desktop main 11051 通过 / renderer 6845 通过、typecheck、check:i18n、
check:i18n-glossary 通过。新增 7 个用例(独立账本 ×3:主账本无自定义 source、
模拟旧版重写后溯源存活、混写存量归位;同名异源拒更新且列表标 conflict;打包期间
来源被移除则拒装;current.bak 下推迟删除不误删当前版本;update-available 夹具补
指纹),逐个验证过"退回修复即失败"。`makerSendToSessionOrdering` 与
`scriptRunnerPythonProtocol` 的 6 个失败为合并进来的 upstream/main 既有失败,
与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 按规则文档全面自查——detail 脱敏、清单资源上限、名称边界、指纹防碰撞

按 docs/dev-rules 对 PR 全改动面做主动排查(electron-security / credentials /
plugin-security / media-storage / engineering-conventions 逐清单对照),一次性修掉
排查发现的问题与随后进来的 2 条 review(与排查结果重合):

1. discover 的错误 detail 把宿主绝对路径直达 Renderer(与已修的 git stderr 同族)。
   realpath / readFile 失败的 message 自带完整路径,经 throwIpcError 原样转发。
   导出 git.ts 的 redactAbsolutePaths 供 discover 共用,两处 catch 的 detail 均经
   脱敏并截断到 256 字符。

2. 清单与身份卡无资源上限(Codex 同日也点了此条)。市场仓库是不受信内容,git clone
   不限单文件大小:数 GB 的 marketplace.json / ghost.json 会在 readFile + JSON.parse
   里耗尽 main 进程内存;十万条目的清单会把快照/列表/刷新拖死(每条目 realpath +
   读身份卡)。新增:清单须为普通文件且 ≤1MiB、插件条目 ≤512(超限拒绝);
   ghost.json ≤512KiB(超限按条目非法跳过,不拖垮市场)——都在 readFile **之前**
   按 stat 判定。

3. 市场名与 displayName 无边界。名称进 store 持久化、进 pluginId、进 UI 与路径
   slug:超长导致配置无界膨胀,控制字符/双向文本控制符可伪装 UI 文案与污染日志。
   名称 >128 字符或含禁用字符拒绝整个市场;displayName 是描述性字段,非法只置
   null 不拒市场。

4. marketSourceKey 拼接式指纹存在可构造碰撞(Copilot 点出 sparsePaths 的
   join(',');自查发现 ref 含 ':' 同样可碰撞:ref:'x'+['p'] 与 ref:'x:p'+[] 同
   key)。而这个 key 承担账本所有权判定,碰撞 = 同名异源防线失效。改为 JSON 数组
   序列化(无歧义定界);marketCloneSlug 复用同一定义随之修正。特性未发布,
   slug/指纹变化无存量影响。

5. 防御性说明加固:SOURCE_MUTATION_KEY 写明锁次序不变量(pluginId 内可取来源锁、
   反向禁止)与"刷新持锁期间提交段排队"的有意权衡;cacheLease 写明字符串比较大小写
   敏感的适用前提(所有路径同源派生)。

6. 按 plugin-security-and-authoring.md 的 PR 义务补齐描述:基座确认门声明(本 PR
   命中白名单,需把关人明确 Approve)、FORGE_GUIDE 无需同步及理由、device-link
   不登记及理由、媒体总仓与凭证落盘的对照结论。

排查中确认无问题的项(记录以免复查):forge 打包 walk 只认 isFile/isDirectory,
symlink 条目不进包(无逃逸);新增 6 个 IPC 错误码在 renderer errorKey 全覆盖;
displayName 未被任何投影消费、不出 main;临时文件均在 temp 且双路径清理;锁图无环。

存量插件影响:无(名称/大小上限只作用于尚未发布的自定义市场内容)。

验证:desktop main 1296(plugin-market 187)/ renderer 101 全过、typecheck、
check:i18n、check:i18n-glossary 通过。新增 7 个用例逐个验证过"退回修复即失败";
其中 detail 脱敏与 ghost.json 大小两条的第一版没有牙齿(悬空 symlink 走不到目标
catch、超大身份卡本来就过不了校验),已分别改成 mock realpath 定向触发与
"断言未发生 readFile"。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 身份卡 lstat 拒 symlink,账本合并按状态与时间消解同 ghostId 冲突

轮询到的 2 条 P1,随同步 upstream/main 一并处理:

1. ghost.json 自身可以是 symlink(P1,DoS + 投影外部内容)。realpath 只校验了插件
   目录,身份卡文件仍可指向市场外——stat 跟随链接,`/dev/zero` 这类特殊文件
   size 为 0 还能绕过大小闸,readFile 会无限读到 OOM;指向普通外部 JSON 时会被
   解析并投影给 Renderer。改用 lstat(不跟随)并要求普通文件:目录已 realpath,
   最后一段拒掉 symlink 后链接无处藏身,与打包侧"符号链接一律不穿透"同口径。
   清单侧无需改:realpath(manifestPath) 已解析链接并做根包含校验,根外目标
   (含 /dev/zero)在包含性判定即被拒。

2. 账本合并让陈旧自定义记录覆盖服务端记录(P1,降级窗口)。降级后旧版卸载自定义
   安装并从服务端装同 ghostId(只写主账本),custom-ledger 里留着旧版不认识、也
   不会清理的陈旧记录;升级回来后无条件的 custom 覆盖会把服务端安装错误归属给
   自定义来源并允许其提供更新。新增 preferRecord 消解:仍在安装中的优先于已卸载
   的,同态按 updatedAt 新者胜,完全平手保主账本(冲突本身意味着旧版操作过主
   账本);胜出后由任意一次写入按 source 归位,败方随整份重写清掉。

同步 upstream/main(c1f3c88,51 个 commit,零冲突),带入 makecindy#1267 的 i18n 修复
(上一轮 verify 40 秒即挂正是 check:i18n 阶段)。

存量插件影响:无。

验证:desktop main(plugin-market 189)/ renderer 6931 全过、typecheck、
check:i18n(6287 key 四语言一致)、check:i18n-glossary 通过。新增 2 个用例均验证
过"退回修复即失败"——symlink 用例指向的是市场外一份**内容合法**的身份卡,退回
lstat 后它会被接受并投影('stolen' 出现在插件列表),只断言"被跳过"没有这个区分度。
makerSendToSessionOrdering 与 scriptRunnerPythonProtocol 的 6 个失败为本地环境差异
(CI verify 曾全量通过),与本 PR 无关。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): manifest 摘要绑定所有权、异常出口校验代际、清理未用导入

本轮 3 条 review(Codex ×2 P1 + Copilot ×1):

1. 降级期间被本地替换的包不得被陈旧自定义记录认领(P1)。新版装自定义 A →
   降级 → 旧版卸载 A 并从本地 .cindy 装同 ghostId 的 B → 升级回来:旧版不认识
   custom 账本,记录原样留存(installed:true、pluginId/sourceKey 全对),B 会被
   错误归属给 A 的来源并允许其更新覆盖。账本记录新增 manifestDigest(安装落位
   那一刻的 manifest 规范化摘要,递归键排序后 sha256,与键序/来源无关);
   customInstall 与列表投影的所有权判定升级为 pluginId + sourceKey +
   manifestDigest **三者同时**对上——运行时的 manifest 不再是安装时那份,认领
   即失效,fail 向 conflict(交还用户处置)。字段存于独立 custom 账本,不受旧版
   schema 约束。

2. runForOwner 的异常出口不校验账户代际(P1)。operation 抛出时跳过
   requireSameMarketOwner,git/discover 类错误的 detail 刻意保留了仓库地址等
   上一账号私有信息,切号后的 Renderer 能看到。catch 里同样先校验代际,漂移时
   统一替换成 PRECONDITION_FAILED——原始失败对当前会话没有意义。

3. sources/store.ts 留了一个未使用的 isAtomicBackupUnrecoverable 导入(上一轮
   重构读取路径时遗留),删除。

存量插件影响:无(manifestDigest 只作用于尚未发布的自定义安装记录)。

验证:desktop main(plugin-market 200)/ renderer plugin 101、typecheck、
check:i18n、check:i18n-glossary 通过。新增 2 个用例均验证过"退回修复即失败"
(替换包用例退回后精确报 update-available、代际用例退回后泄出原始
MARKET_SOURCE_INVALID)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 显式 Git ref 用 --detach 消歧,防同名文件劫持固定引用

Codex 实测核对的真实固定引用失效:`git checkout <ref>` 在 ref 不存在、但仓库根
恰有同名文件时会退化成**路径检出**并成功返回,HEAD 仍停在默认分支——用户 pin 住
tag/commit,实际安装的却是默认分支内容(供应链完整性问题)。plain 与 sparse 两条
克隆路径的显式 ref 一律改为 `checkout --detach <ref>`:强制按 commit-ish 解析,
解析不了就如实失败(MARKET_REF_NOT_FOUND 分类照旧)。无 ref 时保持"checkout
默认分支"的既有行为(那是为了刷新可快进,不受此歧义影响);刷新的 pin ref 路径
本就走 fetch + reset --hard FETCH_HEAD,无歧义,不改。

同轮另两条(切号后表单草稿残留、确认框残留)判定为非必要修改,在 thread 里说明
理由后 resolve,不改代码:实际副作用(安装)已被 busy lease 与 Main 侧代际校验
挡住,残留的是当前设备同一操作者自己刚输入/查看的瞬时内容,与已修的"上一账号
持久数据泄露"不同性质。

存量插件影响:无。

验证:plugin-market 191 全过、typecheck 过。--detach 断言直接写进两条既有用例,
退回即失败。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): locale 无关的安装摘要,目录不完整时禁自动安装与所有权提交

本轮 2 条 Codex P1,核实后都不属于"评论关闭"档:

1. manifest 摘要受界面语言影响(P1,**上一轮 manifestDigest 的回归**)。核实
   GhostManager.list() 返回的 manifest 确实是按当前语言本地化后的
   (readInstalledLocalizedManifest)——上一轮用它算摘要,用户装完切换应用语言,
   自装插件全部被误判成"包被替换":列表 conflict、更新被 ALREADY_EXISTS 拒。
   摘要两侧统一改为 locale 无关输入:写入侧用发现层的原始 manifest(校验器
   输出);比对侧从安装目录重读原始 ghost.json、过同一 validateGhostManifest 再
   规范化(读不出/校验不过 → null → 不认领,fail 向 conflict)。投影侧的摘要在
   localInstallSnapshot 一次快照只读一遍盘。

2. 来源发现失败被当成"目录为空"(P1)。任一来源因缓存指针/网络盘/本地目录暂时
   不可读而失败时,容错枚举把它跳过,combinedDuplicateGhostIds 少了它声明的
   ghostId——defaultInstall 会自动装上重名的服务端插件,手动安装的所有权断言也
   放行,坏来源恢复前"先装先得"被抢占。把"目录不完整"与"来源确实为空"分开:
   discoverCustomEntriesSafe 返回完整性;不完整时跳过全部默认安装(记日志),
   assertSourceOwnsGhostId fail closed(PRECONDITION_FAILED,提示先刷新或移除
   坏来源)。展示路径照旧容错,单源失败不拖垮列表。

存量插件影响:无。

验证:plugin-market 202 / renderer plugin 101 全过、typecheck 过。3 个新/改用例
均验证过"退回修复即失败"(update-available 夹具改为"目录放原始 ghost.json +
运行时挂本地化变体",退回 T2 后它精确失败——这正是切语言场景)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 打包快照防 TOCTOU、本地目录走原生选择器授权、账本丢失可收养自愈

本轮 4 条 Codex,修 3 条、1 条经判定评论关闭(见 PR thread):

1. 打包 TOCTOU(P1)。buildGhostPackage 校验 ghost.json 后,生成 zip 时**逐文件
   重读磁盘**——并发改写(保 id/version、偷加权限声明)会让"返回给安装侧做审阅
   比对的 manifest"与"包里的 manifest"分叉,自称的防篡改防线被打穿。第一次读到
   的原始字节留作不可变快照,写入 zip 的 ghost.json 一律用它:校验的 = 返回的 =
   包里的,三者恒等。

2. Renderer 直传本地路径不构成授权(P1,electron-security 规则)。XSS 控制下的
   Renderer 可提交任意绝对路径让 Main 读取并允许打包安装;frame 校验只证明来源
   窗口。本地目录改走 Main 原生目录选择器:新增 plugin-market:pick-local-source
   (dialog.showOpenDialog,用户的选择即授权;Renderer 传的路径只作初始定位提示),
   add-source IPC 对解析为 local 的来源一律拒绝。Git 源输入流程不变。

3. 账本写失败后市场链路死结(P2 实质)。包已落位但 custom-ledger 写入失败(文件锁/
   磁盘错)时,记录缺失 → 快照永久 conflict(UI 禁用安装,连重试按钮都没有)、更新
   永被 ALREADY_EXISTS 拒。新增"收养"自愈:运行时已装内容的原始 manifest 摘要与
   本来源候选**完全一致**时,列表投影成可安装、customInstall 放行完整重装并补写
   溯源。安全性:收养走完整重装(落位字节来自本来源的包)+ 用户刚审阅过同一份
   manifest;声明有任何差异收养不了(与降级替换防线不冲突)。

4. "整包内容摘要"意见判定为评论关闭:manifest 完全等价时替换包的对外声明就是
   同一插件同一版本,边际极低;完整解(安装 receipt)归属 makecindy#636 重做的正式跟踪
   (docs/dev-rules/plugin-security-and-authoring.md §7),且本次的收养机制要求
   重装落位,来源更新覆盖的字节始终来自来源包。

存量插件影响:无。手册(FORGE_GUIDE)无需同步:打包限制与作者可见契约未变,
快照写入是实现细节。

验证:desktop main 1341(plugin-market 200 / cindy-brain 52)/ renderer plugin 101
全过、typecheck、check:i18n 通过。3 个新用例逐个验证过"退回修复即失败"(TOCTOU
用例退回后精确显示偷加的 network 权限被打进包)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 选择器跨切号固定 owner,身份卡校验与读取共用同一文件句柄

本轮 2 条 Codex P1,都是对上一 commit 新代码的精确跟进,都修:

1. 目录选择器打开期间切号(P1)。原生框可以开着很久,owner 在用户选完后才捕获——
   账户 A 发起的选择会被持久化进此时已激活的账户 B,绕过既有代际校验。改为打开
   选择器**之前** captureMarketOwner,返回后 requireSameMarketOwner 同代际校验,
   漂移即拒(PRECONDITION_FAILED),再让 addSource 在同一 owner 下落盘。

2. lstat 与 readFile 是两次独立打开(P1)。并发方可在 lstat 通过后、readFile 打开
   前把 ghost.json 换成超大文件或指向 /dev/zero 的链接,绕过类型与大小闸。改为
   单一文件句柄完成全链:open(O_RDONLY | O_NOFOLLOW,Windows 无此 flag 按 0,
   NTFS 链接需管理员权限) → handle.stat 校验普通文件与大小 → 同句柄限量读——
   检查与读取作用于同一 inode,路径再被替换也影响不到它。清单侧同样句柄化
   (不加 NOFOLLOW:realpath 已解析链接并做过根包含校验,根内链接是允许的)。

存量插件影响:无。

验证:desktop main 1343 全过、typecheck 过。2 个新用例 + 1 条结构断言
(ghost.json 不得再经路径式 lstat/readFile)均验证过"退回修复即失败"。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 市场身份卡读取统一走单句柄限量闸,补 Windows 无 O_NOFOLLOW 回退

两条 review 意见,同一类缺口的两个残留面:

- Codex P1(install.ts / forge.ts):发现层句柄闸修好后,安装前置校验与打包
  的清单读取仍在按路径无界 readFile。详情展示后、确认安装前,本地市场目录
  是用户可写的活目录,把 ghost.json 换成超大文件或 /dev/zero 链接即可绕过
  发现层的类型与大小闸,卡死或耗尽 main。
- Copilot(discover.ts):`O_NOFOLLOW ?? 0` 在无该 flag 的平台(Windows)上
  退化为跟随符号链接,丢掉了上一版 lstat 闸的链接拒绝语义。

修法:抽公共工具 readBoundedFileNoFollow(main/utils/readBoundedFile.ts),
单句柄完成"拒符号链接 → 校验普通文件与大小 → 限量读取";无 O_NOFOLLOW 时
回退为 open 后 lstat 路径拒链接 + dev/ino 比对句柄,语义与 POSIX 侧一致。
发现(discover)、安装前置(install)、打包快照(forge)三条链路全部接入,
install.ts 加结构守卫:源码不允许再出现按路径的 readFile。

存量插件影响:无。只收紧不可信市场目录的读取边界;合法身份卡(普通文件、
≤512KiB)行为不变,已装插件不受影响。forge 打包侧对"ghost.json 本身是符号
链接"的拒绝与既有"符号链接一律不穿透"口径一致。

验证:desktop typecheck 过;main 套件 11357 过(6 个既有基线失败与本改动无
关);新增 7 个用例全部验过牙(逐个回退修复,对应用例确实失败)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 打包管道全量接入单句柄限量闸,读满循环消除网络盘短读截断

上一轮只给 ghost.json 接了句柄闸,本轮把同类缺口一次封死,不再逐面补:

- Codex P1(forge.ts zip 阶段):walk 通过大小预估后,文件仍可在 zip 逐文件
  readFile 前被并发换成超大文件或 /dev/zero 链接,无界字节先于整包体积校验
  进入 JSZip。现 zip 逐文件走 readBoundedFileNoFollow,按**剩余总预算**限量,
  任何并发改动(换链接/删除/膨胀)结构化拒绝(TOO_LARGE)。
- 同类一并接闸:SKILL.md 校验读取(原读后才验大小)、目录 locale 校验
  (原 statSync+readFileSync 两次打开,新增同步变体 readBoundedFileNoFollowSync)。
- Codex P2(readBoundedFile.ts):单次 FileHandle.read 在网络盘/FUSE 可能短读,
  合法文件被截断成解析失败。抽 readToLength 读满循环,异步/同步/跟链接三个
  变体共用;discover 清单侧同步收编为 readBoundedFileFollowLinks。
- 结构守卫:forge.ts 与 ghostLocaleFiles.ts 源码出现按路径 readFile/readFileSync
  即测试失败,与 install.ts 的守卫同口径,防这一类第三次回潮。

存量插件影响:无。只收紧不可信目录读取边界;合法插件目录(普通文件、限内)
打包与校验行为不变,已装插件不受影响。

验证:desktop typecheck 过;main 套件 11362 过(6 个既有基线失败无关);新增
用例逐个验牙(退回读满循环/zip 预算闸/同步回退闸,对应用例确实失败,结构守卫
对 zip 退回同样咬住)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 全面梳理封口——不可信目录读取/中间链接/IPC 脱敏/装入竞态一次修净

不再逐轮跟单条意见:用 reviewer 的口径把整个 PR 面完整过一遍,把同类的所有
残留缺口一次修完。含两条挂起意见 + 四镜头审计(并发窗口/IPC 脱敏/Windows
语义/不可信读取)确认成立的项。

单句柄限量闸补全(readBoundedFile.ts 加 containWithin 根内复核 + 同步/跟链接
变体 + 读满循环):
- 中间目录 symlink 窗口:O_NOFOLLOW 只护最后一段,realpath 校验后父目录可被
  换成根外链接。containWithin 在 open 后复核"stat dev/ino 与句柄一致且 realpath
  仍在根内"。discover/install/forge 全部锚各自 realpath 根。
- 已安装目录裸读接闸(每次市场快照都跑):GhostManager list 的 ghost.json、
  icon(statSync+readFileSync 两开→可越根读并把字节经 IPC 送 Renderer)、
  .cindy-trust.json、locale;service.installedGhostRawManifestDigest。
- .cindy 源包 stat-then-read 两开 → 单句柄限量读。

IPC 脱敏 / 供应链:
- parse.ts:拒 URL 含反斜杠(WHATWG 把 \ 归一、git 按原样解析 authority →
  凭证闸失明 + 连接 host 不一致);URL/sparsePaths 补控制符+bidi 闸;new URL
  抛错改 fail-closed。
- git.ts:userinfo 整体掩码(单段 token insteadOf 重写场景 user:pass 规则盖不住);
  sanitizeGitDetail 末尾剥控制符/bidi;加 longpaths/windowsHide/askpass 置空。
- install.ts:packed.message 与 validated.reason 经脱敏+截断再进 IPC。
- service.ts 展示投影:自定义插件 name/description/author 剥双向控制符(consent 欺骗)。

装入竞态 / 一致性:
- 市场装入通道钉 expectedPackageSha256(inspect 与 install/update 各自重读磁盘,
  临时 .cindy 两读间被替换则前置校验全落在旧字节上)。
- customInstall beforeCommit 复核 runtime 所有权(不只来源):打包窗口内本地页
  卸载→防"更新"降级为"首装+带电";窗口内新装同 id→防静默覆盖并绕过权限 diff。
- reconcileRemovedInstallations 的在场判定挪进 ledger 锁内即时重取(update 的
  两次 rename 间的瞬态会被误判成卸载,永久写进 optOut)。
- forge:符号链接条目结构化拒绝(原静默丢弃→装出缺入口的包);mustExist 用
  lstat 与收集侧同语义;临时包 0o600;node_modules 大小写不敏感跳过。
- customSourceNamesSafe 先 requireSameMarketOwner 再解析 owner 路径。

结构守卫扩到 service.ts(禁按路径 readFile/readFileSync)。存量插件影响:无,
只收紧不可信目录读取与不受信内容展示边界。

验证:desktop typecheck 过;main 11372 过(6 个既有基线失败与本改动无关);
新增/改动用例逐个验牙(containWithin/parse 反斜杠/git 掩码/git bidi/icon 链接/
投影 bidi/runtime 复核,退回对应修复均失败)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 收口四镜头审计余项——ghostId 互斥锁/装入边界/Windows 健壮性

延续"封类不逐点":把并发/Windows/边界三镜头审计里确认成立的余项一次修净,
含两条新挂起 P1(均针对上一 commit 引入的代码)。

装入并发(codex P1 + 审计 F1/F2/F3):
- 新增按 ghostId 的真正互斥锁 ghostInstallLock(GhostMutationCoordinator 只是
  账户 drain barrier、可并发持有,不提供互斥)。市场装入、本地 .cindy 装入/
  更新、卸载全部经它按 id 串行(不同 id 仍并行);市场路径的提交段(runtime
  复核 + 落位)整体纳入,复核结论到落位之间同 id 的本地装入/卸载再也插不进来。
  不可重入:只在最外层获取,installAndDock/manager.update 内部不获取。
- reconcileRemovedInstallations 的"是否仍在装"判定挪进 ledger 锁内即时重取
  (update 两次 rename 间的瞬态不再被误判成卸载而永久写进 optOut)。

装入边界(greptile P1):
- install 前置:重解析 pluginDir 后要求仍等于发现时的规范路径。发现后目录或
  父目录被换成指向市场外的链接(留同样 ghost.json)时 realpath 改变即拒,
  payload 不进打包——清单摘要复核只比对清单本身,盖不住非清单文件的替换。

Windows / 跨平台健壮性:
- readBoundedFile:dev/ino 改 BigInt 比较并拒 0(NTFS FileId 截断误相等、
  SMB/FUSE 填 0 会让回退闸退化);拒 0 时按不可信拒绝。
- atomicWriteFile:rename 白名单加 EACCES/EBUSY 短退避重试(Windows 真实瞬时锁);
  成功落位后清理 .bak 失败不再把成功报成失败(rmSyncQuiet)。
- 目录 rename(add/refresh 落位、git clone 落位)瞬时锁重试;cacheLease 的 rm
  带 maxRetries;cloneRoot 顶层 .incoming-*/.staging-* 残骸在新 add 前回收。
- 版本目录名比较改大小写不敏感(备份还原/跨 FS 迁移的大小写漂移不再误删
  current);旧布局迁移用 lstat 拒符号链接槽,链接条目不搬。
- git 执行器:core.longpaths / windowsHide / GIT_ASKPASS,SSH_ASKPASS 置空。

结构门禁扩到整个 sources/(不只 index.ts);git.ts 的私有 staging 删除显式白名单。
存量插件影响:无。验证:desktop typecheck 过;main 11384 过(6 既有基线无关);
新增/改动用例逐个验牙(互斥串行/装入边界/dev-ino 拒 0/atomic 重试+清理不外抛)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): ghost 装入锁改可重入并收口到卡点;插件级读取失败改 fail closed

两条 P1 都是上一 commit 引入的**不对称**:我给相邻路径加了闸,漏了这两条。
这次按"卡点 + 可重入"封类,而不是再给眼前这条路补 guard。

服务端市场装入未进 ghost 锁(codex P1):
- withGhostInstallLock 改**可重入**(AsyncLocalStorage 记持有集合),从而能同时加在
  三个卡点上而不自锁:installAndDock(ghostId 改必填,签名逼着新路径交出 id,锁自动
  获取)、installOrUpdateMarketGhostPackage、uninstallGhostAndCleanup。新增装入路径
  即使忘了取锁也被卡点兜住——这是本轮反复返工的根因(逐点补永远漏下一条)。
- 服务端市场提交段补取同一把锁,并把**账本写入一起纳入锁内**;自定义路径同样经新增
  afterCommit 钩把溯源写入移进锁内。原先账本写在锁外:本地装入能插在"包已落位"与
  "写下溯源"之间换掉同 id 的包,服务端记录不带 manifestDigest,投影时判不出来,
  于是账本认领一个已被替换的包,后续市场更新静默覆盖本地安装。
- 锁序不变量写进注释:pluginId → SOURCE_MUTATION_KEY → ghostId → ledgerMutation。
  卸载路径为 pluginId → ghostId → ledger(不取来源锁),与该偏序相容,无环。
- installAndDock 加纵深防御:落位包的 id 与加锁用的 id 不一致则 fail loud
  (给错 id 等于锁上在错误的键上)。

插件级读取失败被当成内容非法(codex P1):
- resolvePluginDir 返回三态(ok / invalid / unreadable)。EACCES/EBUSY/EIO/ESTALE 等
  可重试错误 = 事实不明 → 新增 unreadableCount,>0 让 discoverCustomEntriesSafe 判
  complete=false,写路径 fail closed(与来源级失败同一口径,粒度到条目)。
- **刻意不含 ENOENT/ELOOP**:清单指向已删目录是常见永久错误,当成"暂时读不到"会让
  这类市场永久阻塞默认安装;ELOOP 是拒符号链接的结果,属永久非法。附对照用例守住。
- skippedCount 此前是死数据(无人消费),现在与 unreadableCount 语义分明。

存量插件影响:无(只收紧并发边界与"事实不明时的写路径")。验证:typecheck 过;
main 11389 过(6 既有基线无关);4 组用例逐个验牙(去掉重入 → 3s 超时自锁;退回
服务端锁 → 外部同 id 请求在落位期间进入且看到账本未写;退回 unreadable 闸 →
默认安装照常下载)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 打包器不再自我参照,规范根由调用方贯穿传递

greptile P1:安装侧按 realPluginDir 复核完清单后,把原始 input.pluginDir 交给
打包器,打包器**自己再 realpath 一次并以此为 containWithin 锚点**。目录或其父目录
在两次解析之间被换成指向市场外的链接时,锚点就是那个外部目录,以它为准的包含性
判定自然全部通过,外部 payload 会被打包安装(外部目录只要留着同样的 ghost.json,
清单对账也发现不了)。

它建议的"改传 realPluginDir"不足以修好:此处两者字符串相等(上一 commit 已断言),
换个变量名关不上任何窗口。真正的缺口是**下游重新发明锚点**,所以修法是让锚点
贯穿传递、并由下游核对:

- buildGhostPackage 增加 expectedRealDir:传入时核对自己解析出的 realpath 仍等于
  它,不等即 MANIFEST_INVALID。
- packGhostDirToFile 的 expectedRealDir **必填**(该路径的输入来自用户可写的市场
  目录):签名逼着新调用方交出上游已校验的规范根,无法跳过、也无法自我参照。
  packGhostDir(Forge 工具路径,用户自选目录)保持可选。
- install.ts 传 realPluginDir 同时作为打包输入与锚点;并把"规范根等值校验"移出
  下面那个 try —— 它的 catch 会把一切压成"清单读不出来"的通用文案,这条原因
  需要如实区分(新增 'The Plugin directory is unreadable' / 保留
  'changed after discovery')。

中途替换仍由既有纵深防线兜住:逐文件读取的 containWithin 锚在同一个规范根上,
配合 dev/ino 复核,打包途中被换链接会让那些读取返回 null 并结构化拒绝。

存量插件影响:无。验证:typecheck 过;main 11392 过(6 既有基线无关);新增 3 个
用例并验牙(退回锚点校验 → 外部 payload 被打包、用例失败;锚点传错值 → 契约用例
失败;规范根一致的正常打包不误伤)。

Signed-off-by: xxxcc <xxxcc123@126.com>

* fix(desktop): 来源身份判据统一到 marketSourceKey;清单读取失败留 main 日志

维护者重档 auto-review 的 2 处 P1(均为纵深防御/可观测性,非安全漏洞):

- store.ts `sourcesEqual()` 用 `sparsePaths.join('\n')` 比较列表 —— 分隔符碰撞类
  写法(`['a\nb']` 与 `['a','b']` join 结果相同 → 两个不同来源被判成同一个)。
  改为直接复用 `marketSourceKey()`:它是本仓对"来源身份"的唯一定义(账本所有权
  校验、缓存 slug 都用它),再写一套等价逻辑只会漂移。补碰撞对抗测试 + 一致性
  测试(sourcesEqual 与 marketSourceKey 判断必须逐例相同)。
  注:parse 层的 FORBIDDEN_SOURCE_CHARS 已拒掉换行、这条路实际走不通,但身份
  判据不该依赖"上游恰好拦住了某个字符"这种远距离前提。

- install.ts 裸 catch 吞掉 TOCTOU 专用抛错。上一 commit 已把"规范根等值校验"移出
  该 try(专用原因不再被覆盖),本次补齐另一半:catch 先 re-throw 已带 code 的
  IpcError,再把原始 error 经脱敏写进 main 日志。Renderer 仍只拿通用文案,但事后
  排查能区分"清单确实损坏"与"有人中途替换了插件目录"。

存量插件影响:无。验证:typecheck 过;main 11395 过(6 既有基线无关);4 个用例
逐个验牙(退回 join 写法 → 碰撞用例失败;退回裸 catch → 日志用例失败)。

Signed-off-by: xxxcc <xxxcc123@126.com>

---------

Signed-off-by: xxxcc <xxxcc123@126.com>
Yuewei-LL added a commit to Yuewei-LL/cindy that referenced this pull request Aug 3, 2026
对照 issue makecindy#1243 验收清单补齐四项(前一 commit 已覆盖其余各项)。

## 1. 本地包第三条恢复路径(验收第 3 条:不存在不可恢复状态)

迁移之后批准再丢失(receipt 损坏/被删,一次性门不再迁移)时,本地包用户原来只有
「重新提供原始 .cindy」一条死路。新增「从已装目录重新确认」:

- `GhostManager.inspectInstalledReapproval(id)`:从安装目录读出确认卡事实(manifest +
  trust + 清单字节指纹),零副作用;`reapproveInstalled(id, {enable,
  expectedManifestSha256, expectedInstalledApproval})`:用户点过确认卡后开 receipt。
- **这是一次真实的用户确认**(全量权限清单逐条展示,无批准基线一律按新增项列),
  与迁移的本质区别在此;与迁移的共同点:trust 走同一个封顶读取器、技能字节现算钉
  指纹、只写状态根。来源记 `user-reapproval`。
- 两道与更新流程同形的绑定:`expectedManifestSha256` 钉住「确认卡展示的」与「批准的」
  是同一份清单字节(确认间隙 ghost.json 被换 → 拒 state-changed);
  `expectedInstalledApproval` 防确认期间批准状态被并发改写,且已 approved 一律拒
  (不给覆盖既有批准开口子)。
- IPC `ghosts:reapprove-inspect` / `ghosts:reapprove-installed` + preload + 类型;
  renderer `reapproveInstalledGhost()` 接进 handleReapprove 的本地包路线,安装目录
  读不出清单时自动回退到原「重新选包」流程(目录坏了才需要找文件)。
- 随包插件一律拒走此路(见第 3 项);trust 镜像自称 cindy-official 一律封顶拒收 ——
  官方档只该由 provisioning 逐字节对账后授予,非随包目录出现官方档镜像本身不可信
  (封顶实现在 readLegacyInstallTrust,迁移与本路径共用)。

## 2. schema/编码 bump 的存量语义(验收第 4 条)

- 新增回归用例:首轮迁移把 schemaVersion 过时(判 invalid)的 receipt 从安装目录
  backfill 治愈,用户停用决定保留 —— 格式升级不落到用户重新确认。
- 规则文档 §3/§5 写明:**从 v2 起任何 bump 必须实现「按旧编码核对 → 原地升级」**;
  v1→v2 无专门读取器的原因(v1 从未随构建发布 + 一次性迁移效果覆盖)已注明,
  不得再引用为豁免。

## 3. 随包插件隔离态死路文案(issue 附带毛边 1)

随包插件批准态异常时,原文案「请重新选择安装包」是死路(随包没有 .cindy 可选)。
- `InstalledGhost` 新增 `builtin` 投影(main 按种子清单判,renderer 不自判前缀 ——
  企业种子不带 cindy- 前缀);
- 详情页说明卡换 `bodyBuiltinRestart`(「重启 Cindy 即可自动恢复」,四语),列表卡与
  详情页的「重新确认权限」按钮对随包隐藏,handleReapprove 双保险拦截。

## 4. 常驻随包插件从隔离自愈后同会话补点火(issue 附带毛边 2)

makecindy#1080 原判「批准自愈不自动重启」的理由是缺少"用户此刻想让它跑"的信号。本次收窄
范围后补上:只对**本会话内因撤销批准被我们熄灯**的常驻声明插件,在批准自愈那一轮
恢复点火 —— 重启只是恢复熄灯前的既有状态,不是新决策(常驻声明本身就是"启用即应
运行",启动扫描无条件点火)。点火仍走 spawnIfResident(自查启用态与会话可用性,
停用的不会被点亮)。其余情况维持不自动重启。

## 回归覆盖

GhostManager.test.ts 新增 6 条:恢复路径 happy path(含 .disabled 镜像同步)、确认
间隙换清单被拒、随包拒走人工确认、已 approved 拒重复确认、official 镜像封顶、
首轮迁移治愈损坏 receipt。renderer 侧 builtin 字段补进现有夹具。

## 验证

pnpm --filter desktop typecheck PASS;定向 117 files / 1507 tests PASS(main
cindy-brain + plugin-market + renderer 插件面);pnpm check:i18n / check:i18n-glossary
PASS;pnpm test:unit 17712 passed、零新增失败(9 suite 失败与前轮完全相同,全部在与
origin/main 零 diff 的文件里:cindy-media better-sqlite3 collect ×8、skillhub Windows
路径分隔符 ×2、mobile 源码扫描 ×1,环境/存量问题)。

第 4 项(隔离自愈点火)在 index.ts 私有对账函数内,无直测(成因与此前登记一致:
index.ts 拉起整张 main 单例图);其依赖的公开契约(approveTrustedBundledInstall 的
隔离清除与启停合并、spawnIfResident 的启用自查)已有既有用例覆盖。

Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
MagicLizi pushed a commit that referenced this pull request Aug 13, 2026
This reverts commit eb6556d(那条 commit 回滚了 #1080 的 merge 890d4b3)。

#1080 的安全方向是对的(#636:就地改写安装目录让权限 diff 以被改过的现场为基线,
未确认的 slot 因此拿到运行授权),被回滚只因落地方式违反了随后新立的规则 §5「存量
插件兼容:升级必须无感」—— 把「无 receipt = 不构成运行授权」一次性作用到全部存量安装,
市场/本地包没有 backfill,升级后全部非随包插件同时失效。

本 commit 只做机械 reland:把 #1080 的全部实现原样带回(revert 那个 revert),与回滚后
落地的 4 个 PR(SuperGrok/ChatGPT 图片、确认卡刷新、refMode 视频)自动三方合并、零文本
冲突,typecheck + src/main/cindy-brain 84 files / 1187 tests PASS。

规则文档三方合并结果符合预期且刻意保留:
- §3 恢复 #1080 的 receipt / ghostContentTree / 强化 skill 槽描述;
- §5「存量插件兼容」红线(b7ebb8f 独立提交所立、非 #1080 内容)完整保留 —— 它正是本次
  reland 的验收标准;
- §7 第一条「存量安装没有 receipt backfill【未修复|违反第 5 节红线】」保留 —— 这条
  缺口由紧随其后的迁移 commit 消除,本 commit 不动它(reland 只还原代码、不含迁移)。

**单独 reland 尚不可合并**:此刻它与被回滚时的行为完全一致,仍会让存量插件失效。
必须与后续「一次性 legacy backfill 迁移」commit 一起才满足 §5。分成两个 commit 是为了
让 reviewer 能把"代码还原"与"新增迁移"分开看。

Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
MagicLizi pushed a commit that referenced this pull request Aug 13, 2026
#1080 把插件运行授权从可变安装目录搬到 Host receipt,升级前装的插件没有 receipt ——
上一版把它们一律落到 legacy-unapproved、逐个要求重新确认,导致回滚(违反随后新立的
docs §5「存量插件兼容:升级必须无感」红线)。本 commit 补上 §5 要求的一次性 backfill,
让 reland 后的 #1080 满足红线、可以合并。

## 迁移机制

`GhostManager.migrateLegacyApprovalsOnce()`,在每轮 reconcile 前跑(首次之后凭
`.legacy-migration.json` ledger 瞬时 no-op)。对每个没有 receipt 的安装目录,从旧的三份
事实源 backfill 出等价 receipt:
- manifest ← ghost.json(过 validateGhostManifest);
- enabled ← .disabled 镜像(旧模型的启停事实);
- trust ← .cindy-trust.json(新增迁移专用读取器 readLegacyInstallTrust;缺失/损坏降级
  为 unverified —— trust 是展示信号,能力由 slot 授予,不比旧模型少展示);
- skillContentSha256 现算 + 经 receiptStore.write({skillSourceDir}) 建快照(复用既有安全
  机制:逐字节校验、原子 rename、快照发布后复核);
- packageSha256 省略(audit-only、运行期不消费),迁移更快、不因安装目录异常条目误伤。
随包种子 id 跳过 —— 走 provisioning 的 approveTrustedBundledInstall(有权威字节可比)。

## 三条安全不变量(见 GhostManager 头注释与 §5/§7 文档)

1. **全局一次性**:状态根有迁移 ledger 即视为已迁过,此后缺 receipt 一律 fail closed、
   不再迁。否则删掉 receipt 就能骗一次"从可变安装目录重建授权"。这道门是**充分**守卫:
   §7 已登记"状态根无写保护、能写它的进程可伪造结构合法的 receipt",能删 ledger 的进程
   本就能直接写伪造 receipt,迁移路径严格弱于它,不构成新攻击面。
2. **不扩权、不等于新确认**:receipt 权限集 = 当前 ghost.json 声明,等价于旧模型无条件
   授权的那一组;此后任何 manifest/权限变化照旧走完整确认(update 流程不变)。迁移来源
   记进 ledger 的 migratedIds,便于事后分辨"用户确认过"与"迁移来的"。
3. **只写状态根、绝不动安装目录**:三份旧文件原样保留 —— 回滚到旧客户端时它照旧从安装
   目录判定启停,不错位(§5 兜底第 4 条 回滚余地)。

## 分级 fail(§5:核心事实读不出才 fail closed,展示元数据缺失则降级)

manifest 不合法 / 技能目录含链接 / 声明的 locale 装入后损坏 → 该插件保持 fail closed、
走恢复 UI(装入流程本就校验 locale,读到坏 locale 只可能是装入后损坏 = §5 的"自相矛盾");
trust 缺失 → unverified;icon 读失败 → 无图标。迁移整体失败(状态根不可写)不挡播种,
下一轮启动重试。

## 本地体验(验收标准第 2 条)

`pnpm demo:legacy-migration`(scripts/dev-legacy-migration-demo.mts):用真实 GhostManager
在临时目录造旧布局、跑迁移、打印迁移前后 list()、台账、一次性门与回滚余地验证;不触碰
真实数据。脚本末尾附「在真实 app 中体验」的手动步骤(删 receipt + ledger → 重启观察无感)。

## 回归覆盖(§5 硬性:从旧状态升级的自动化用例;关键两条已实测还原判据即精确变红)

GhostManager.test.ts 新增「存量插件一次性迁移」10 条:市场/本地无感迁移(升级后仍启用)、
停用态保留、trust 保留 + 缺失降级、skill 槽迁移后快照校验通过、**全局一次性门挡住二次
迁移**(还原 ledger 门必红)、迁移不改安装目录三文件、坏 manifest fail closed、坏 locale
fail closed、随包 id 跳过、已有 receipt 不覆盖。

## 验证

pnpm --filter desktop typecheck PASS;vitest run src/main/cindy-brain 84 files /
1197 tests PASS。docs §7 首条从「未修复」更新为「已修复|迁移」。

Signed-off-by: Yuewei-LL <308228674+Yuewei-LL@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants